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

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

* chore(ioscompanion): add companion asset prepare script

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

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

* docs: add third party notices for vendored companion

* chore: ignore vendored companion bundle artifact

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

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

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

* feat(ioscompanion): commit generated companion gRPC stubs

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

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

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

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

* feat(ioscompanion): add USB HID keymap module

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

* build: embed companion assets via withcompanion tag

* feat(ioscompanion): add transport companion interface

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

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

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

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

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

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

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

* feat(ioscompanion): add Describe to companion transport

* feat(ioscompanion): implement DeviceDriver with companion supervision

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

* test(ioscompanion): gated companion smoke test

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

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

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

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

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

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

* feat(folio): add iOS conformance gate script

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

* style: gofmt struct alignment drift

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

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

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

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

* test(ioscompanion): cover install payload chunking

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* test(ioscompanion): cover collapsed-dump detection

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

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

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

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

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

* feat(companion): add simulator runner project skeleton

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

* feat(companion): synthesize timestamped touch gestures

* feat(companion): type text with replace semantics

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

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

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

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

* feat(ioscompanion): add runner companion JSON transport

* test(ioscompanion): cover runner transport protocol mapping

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

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

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

* refactor(companion): tidy snapshot serialization

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

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

* chore(companion): add runner asset prepare script

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

* test(ioscompanion): cover runner asset extraction

* build(ioscompanion): commit runner asset archive

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

No files matched your search

@@ -0,0 +1,140 @@
// Package companionassets embeds the simulator companion binary and its
// frameworks, and extracts the bundle to disk at runtime.
package companionassets
import (
"archive/tar"
"bytes"
"compress/gzip"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
// companionBinaryName is the file name inside bin/ fixed by the vendored
// layout. The binary resolves its frameworks through @rpath relative to this
// path, so it must not be renamed.
const companionBinaryName = "idb_companion"
// EmbeddedSize returns the size in bytes of the embedded companion archive.
func EmbeddedSize() int { return len(embeddedArchive) }
// EmbeddedSHA256 returns the hex-encoded SHA-256 of the embedded archive.
func EmbeddedSHA256() string {
sum := sha256.Sum256(embeddedArchive)
return hex.EncodeToString(sum[:])
}
// Extract unpacks the embedded companion archive into dir, preserving the
// bin/ and Frameworks/ layout and any symlinks. A .sha256 marker next to the
// extracted tree gates re-extraction: if it already matches, no rewrite
// happens. Returns the absolute path to the companion binary.
func Extract(dir string) (string, error) {
if len(embeddedArchive) == 0 {
return "", errors.New("companion: binary built without -tags withcompanion; rebuild with `make sanderling`")
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", fmt.Errorf("mkdir %s: %w", dir, err)
}
binaryPath, err := filepath.Abs(filepath.Join(dir, "bin", companionBinaryName))
if err != nil {
return "", err
}
checksumPath := filepath.Join(dir, "companion.sha256")
checksum := EmbeddedSHA256()
if existing, err := os.ReadFile(checksumPath); err == nil && string(existing) == checksum {
if _, err := os.Stat(binaryPath); err == nil {
return binaryPath, nil
}
}
if err := unpack(dir); err != nil {
return "", err
}
if err := os.Chmod(binaryPath, 0o755); err != nil {
return "", fmt.Errorf("chmod companion binary: %w", err)
}
if err := os.WriteFile(checksumPath, []byte(checksum), 0o644); err != nil {
return "", fmt.Errorf("write checksum: %w", err)
}
return binaryPath, nil
}
func unpack(dir string) error {
gzipReader, err := gzip.NewReader(bytes.NewReader(embeddedArchive))
if err != nil {
return fmt.Errorf("open companion archive: %w", err)
}
defer gzipReader.Close()
tarReader := tar.NewReader(gzipReader)
for {
header, err := tarReader.Next()
if err == io.EOF {
return nil
}
if err != nil {
return fmt.Errorf("read companion archive: %w", err)
}
if strings.HasPrefix(filepath.Base(header.Name), "._") {
continue
}
target, err := safeJoin(dir, header.Name)
if err != nil {
return err
}
switch header.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(target, 0o755); err != nil {
return err
}
case tar.TypeSymlink:
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
os.Remove(target)
if err := os.Symlink(header.Linkname, target); err != nil {
return err
}
case tar.TypeReg:
if err := writeFile(target, tarReader, os.FileMode(header.Mode)); err != nil {
return err
}
}
}
}
func writeFile(path string, src io.Reader, mode os.FileMode) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
file, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode)
if err != nil {
return err
}
if _, err := io.Copy(file, src); err != nil {
file.Close()
return err
}
return file.Close()
}
// safeJoin rejects archive entries that would escape dir.
func safeJoin(dir, name string) (string, error) {
target := filepath.Join(dir, name)
relative, err := filepath.Rel(dir, target)
if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(os.PathSeparator)) {
return "", fmt.Errorf("archive entry escapes destination: %s", name)
}
return target, nil
}
@@ -0,0 +1,12 @@
//go:build withcompanion
package companionassets
import _ "embed"
//go:embed assets/companion-1.1.8.tar.gz
var embeddedArchive []byte
// IsPlaceholder reports whether the binary was built without the real
// companion archive embedded. -tags withcompanion builds always return false.
func IsPlaceholder() bool { return false }
@@ -0,0 +1,10 @@
//go:build !withcompanion
package companionassets
var embeddedArchive []byte
// IsPlaceholder reports whether the binary was built without the real
// companion archive embedded. Build with `make sanderling` (which passes
// -tags withcompanion) to embed the real companion bundle.
func IsPlaceholder() bool { return true }
@@ -0,0 +1,27 @@
//go:build !withcompanion
package companionassets
import (
"strings"
"testing"
)
func TestStubBuild_IsPlaceholder(t *testing.T) {
if !IsPlaceholder() {
t.Error("default build (no -tags withcompanion) must report a placeholder")
}
if EmbeddedSize() != 0 {
t.Errorf("placeholder build must embed no archive, got %d bytes", EmbeddedSize())
}
}
func TestStubBuild_ExtractErrors(t *testing.T) {
_, err := Extract(t.TempDir())
if err == nil {
t.Fatal("Extract must fail when no archive is embedded")
}
if !strings.Contains(err.Error(), "withcompanion") {
t.Errorf("error should tell the user to rebuild with -tags withcompanion, got %v", err)
}
}
@@ -0,0 +1,146 @@
//go:build withcompanion
package companionassets
import (
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"testing"
)
func TestEmbeddedNonZero(t *testing.T) {
if EmbeddedSize() == 0 {
t.Errorf("expected embedded companion archive to be non-empty")
}
if IsPlaceholder() {
t.Errorf("withcompanion build should not be a placeholder")
}
}
func TestEmbeddedSHA256Matches(t *testing.T) {
sum := sha256.Sum256(embeddedArchive)
if hex.EncodeToString(sum[:]) != EmbeddedSHA256() {
t.Errorf("EmbeddedSHA256 does not match a fresh hash of the archive")
}
}
func TestExtract_WritesBinaryAndChecksum(t *testing.T) {
directory := t.TempDir()
path, err := Extract(directory)
if err != nil {
t.Fatal(err)
}
expected, err := filepath.Abs(filepath.Join(directory, "bin", companionBinaryName))
if err != nil {
t.Fatal(err)
}
if path != expected {
t.Errorf("unexpected binary path: got %s want %s", path, expected)
}
checksum, err := os.ReadFile(filepath.Join(directory, "companion.sha256"))
if err != nil {
t.Fatal(err)
}
if string(checksum) != EmbeddedSHA256() {
t.Errorf("checksum file content wrong: %q", checksum)
}
}
func TestExtract_LayoutAndSymlinks(t *testing.T) {
directory := t.TempDir()
binaryPath, err := Extract(directory)
if err != nil {
t.Fatal(err)
}
info, err := os.Stat(binaryPath)
if err != nil {
t.Fatalf("companion binary missing: %v", err)
}
if info.Mode().Perm()&0o111 == 0 {
t.Errorf("companion binary is not executable, mode=%v", info.Mode())
}
frameworksDir := filepath.Join(directory, "Frameworks")
if stat, err := os.Stat(frameworksDir); err != nil || !stat.IsDir() {
t.Fatalf("Frameworks directory missing: %v", err)
}
expectedFrameworks := []string{
"FBControlCore.framework",
"FBDeviceControl.framework",
"FBSimulatorControl.framework",
"IDBCompanionUtilities.framework",
"IDBGRPCSwift.framework",
"XCTestBootstrap.framework",
}
for _, name := range expectedFrameworks {
if stat, err := os.Stat(filepath.Join(frameworksDir, name)); err != nil || !stat.IsDir() {
t.Errorf("expected framework %s missing: %v", name, err)
}
}
// The Versions/Current symlink must resolve to a real directory, proving
// symlinks survived extraction.
current := filepath.Join(frameworksDir, "FBControlCore.framework", "Versions", "Current")
linkInfo, err := os.Lstat(current)
if err != nil {
t.Fatalf("Versions/Current missing: %v", err)
}
if linkInfo.Mode()&os.ModeSymlink == 0 {
t.Errorf("Versions/Current is not a symlink")
}
resolved, err := os.Stat(current)
if err != nil || !resolved.IsDir() {
t.Errorf("Versions/Current does not resolve to a directory: %v", err)
}
// A runtime dylib that must be preserved inside FBControlCore Resources.
dylib := filepath.Join(frameworksDir, "FBControlCore.framework", "Versions", "A", "Resources", "libMaculator.dylib")
if _, err := os.Stat(dylib); err != nil {
t.Errorf("expected runtime dylib preserved: %v", err)
}
}
func TestExtract_ReusesWhenChecksumMatches(t *testing.T) {
directory := t.TempDir()
binaryPath, err := Extract(directory)
if err != nil {
t.Fatal(err)
}
sentinel := []byte("SENTINEL-do-not-rewrite")
if err := os.WriteFile(binaryPath, sentinel, 0o755); err != nil {
t.Fatal(err)
}
if _, err := Extract(directory); err != nil {
t.Fatal(err)
}
after, err := os.ReadFile(binaryPath)
if err != nil {
t.Fatal(err)
}
if string(after) != string(sentinel) {
t.Errorf("second extract rewrote the binary; reuse branch should have skipped extraction")
}
}
func TestExtract_RewritesIfChecksumMissing(t *testing.T) {
directory := t.TempDir()
if _, err := Extract(directory); err != nil {
t.Fatal(err)
}
checksumPath := filepath.Join(directory, "companion.sha256")
if err := os.Remove(checksumPath); err != nil {
t.Fatal(err)
}
if _, err := Extract(directory); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(checksumPath); err != nil {
t.Errorf("checksum should have been rewritten: %v", err)
}
}
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env bash
#
# Builds the embeddable simulator companion tarball.
#
# Copies the runtime files from a local install, preserving the bin/ and
# Frameworks/ layout (the binary resolves frameworks through @rpath, so the
# two directories must stay siblings). Build-time metadata that is never
# needed at runtime is stripped to keep the embedded payload small. The
# stripped layout is re-signed ad-hoc and proven to execute before it is
# packaged.
#
# Output: assets/companion-1.1.8.tar.gz next to this script.
set -euo pipefail
# Keep the archiver from emitting AppleDouble (._name) sidecar entries that
# would otherwise duplicate extended attributes into the payload.
export COPYFILE_DISABLE=1
VERSION="1.1.8"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ASSETS_DIR="${SCRIPT_DIR}/assets"
OUTPUT="${ASSETS_DIR}/companion-${VERSION}.tar.gz"
# Default source is the local formula install. Resolve the symlink so the
# rsync below copies real files rather than a dangling link.
SOURCE="${COMPANION_SOURCE:-/opt/homebrew/opt/idb-companion}"
if [ ! -e "${SOURCE}" ]; then
echo "source not found: ${SOURCE}" >&2
echo "set COMPANION_SOURCE to the install prefix" >&2
exit 1
fi
SOURCE="$(cd "${SOURCE}" && pwd -P)"
if [ ! -x "${SOURCE}/bin/idb_companion" ]; then
echo "no companion binary at ${SOURCE}/bin/idb_companion" >&2
exit 1
fi
STAGE="$(mktemp -d)"
PROOF="$(mktemp -d)"
trap 'rm -rf "${STAGE}" "${PROOF}"' EXIT
echo "staging from ${SOURCE}"
# Copy the runtime layout. -a preserves symlinks (the macOS framework
# Versions/Current links must survive intact).
mkdir -p "${STAGE}/bin"
cp -a "${SOURCE}/bin/idb_companion" "${STAGE}/bin/idb_companion"
cp -aR "${SOURCE}/Frameworks" "${STAGE}/Frameworks"
# Strip build-time metadata from every framework. The framework binary, its
# Info.plist, the code signature, and any runtime dylibs are kept; headers and
# the Swift module/source-info artifacts are not loaded at runtime.
for framework in "${STAGE}"/Frameworks/*.framework; do
version_dir="${framework}/Versions/A"
[ -d "${version_dir}" ] || continue
rm -rf "${version_dir}/Headers" "${version_dir}/PrivateHeaders"
rm -rf "${version_dir}/Modules"
rm -f "${framework}/Headers" "${framework}/PrivateHeaders" "${framework}/Modules"
find "${version_dir}" -name '*.swiftmodule' -prune -exec rm -rf {} + 2>/dev/null || true
find "${version_dir}" \( -name '*.swiftdoc' -o -name '*.swiftsourceinfo' \) -delete 2>/dev/null || true
done
# Re-sign each framework and the main binary ad-hoc. Removing files breaks the
# existing signature seal, which the loader rejects on arm64 macOS.
for framework in "${STAGE}"/Frameworks/*.framework; do
codesign --force --sign - --timestamp=none "${framework}" >/dev/null 2>&1
done
codesign --force --sign - --timestamp=none "${STAGE}/bin/idb_companion" >/dev/null 2>&1
# Prove the stripped, re-signed layout still loads and runs. tar stores
# symlinks as symlinks so the extracted copy mirrors the embedded payload.
# --version dynamically links every framework and exits zero on success,
# which fails if a strip or re-sign broke a load command or the seal.
STRIPPED_BYTES="$(find "${STAGE}" -type f -exec stat -f%z {} + | awk '{sum += $1} END {print sum}')"
PROOF_TAR="${PROOF}/payload.tar.gz"
tar -czf "${PROOF_TAR}" -C "${STAGE}" .
tar -xzf "${PROOF_TAR}" -C "${PROOF}"
if ! "${PROOF}/bin/idb_companion" --version >/dev/null 2>&1; then
echo "stripped companion failed to execute" >&2
exit 1
fi
echo "stripped layout executes"
mkdir -p "${ASSETS_DIR}"
tar -czf "${OUTPUT}" -C "${STAGE}" .
SHA="$(shasum -a 256 "${OUTPUT}" | awk '{print $1}')"
echo "wrote ${OUTPUT}"
echo "stripped uncompressed size: ${STRIPPED_BYTES} bytes"
echo "sha256: ${SHA}"
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+853
View File
@@ -0,0 +1,853 @@
package ioscompanion
import (
"bytes"
"context"
"errors"
"fmt"
"image"
"image/color"
"image/png"
"net"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"github.com/priyanshujain/sanderling/internal/driver"
"github.com/priyanshujain/sanderling/internal/driver/ioscompanion/transport"
)
// fakeCompanion is an in-package stand-in for transport.Companion. It records
// the order of calls and returns scripted results, so the driver's decision
// logic is testable without a live simulator.
type fakeCompanion struct {
// callsMutex guards calls: Snapshot captures the hierarchy and the
// screenshot concurrently.
callsMutex sync.Mutex
calls []string
accessibilityJSON string
accessibilityErr error
screenshotData []byte
describe transport.ScreenDescription
apps []transport.InstalledApp
// hidErr fires on the next SendHID then clears, letting a test inject one
// transient failure.
hidErr error
}
func (f *fakeCompanion) record(name string) {
f.callsMutex.Lock()
defer f.callsMutex.Unlock()
f.calls = append(f.calls, name)
}
func (f *fakeCompanion) recorded() []string {
f.callsMutex.Lock()
defer f.callsMutex.Unlock()
out := make([]string, len(f.calls))
copy(out, f.calls)
return out
}
func (f *fakeCompanion) AccessibilityInfo(context.Context) (string, error) {
f.record("accessibility")
return f.accessibilityJSON, f.accessibilityErr
}
func (f *fakeCompanion) Describe(context.Context) (transport.ScreenDescription, error) {
f.record("describe")
return f.describe, nil
}
func (f *fakeCompanion) SendHID(context.Context, ...transport.HIDEvent) error {
f.record("hid")
if f.hidErr != nil {
err := f.hidErr
f.hidErr = nil
return err
}
return nil
}
func (f *fakeCompanion) Screenshot(context.Context) ([]byte, string, error) {
f.record("screenshot")
return f.screenshotData, "", nil
}
func (f *fakeCompanion) Launch(_ context.Context, _ string, _ bool) error {
f.record("launch")
return nil
}
func (f *fakeCompanion) Terminate(context.Context, string) error {
f.record("terminate")
return nil
}
func (f *fakeCompanion) ListApps(context.Context) ([]transport.InstalledApp, error) {
f.record("listapps")
return f.apps, nil
}
func (f *fakeCompanion) Install(context.Context, string) error {
f.record("install")
return nil
}
func (f *fakeCompanion) Uninstall(context.Context, string) error {
f.record("uninstall")
return nil
}
func (f *fakeCompanion) Close() error {
f.record("close")
return nil
}
var _ transport.Companion = (*fakeCompanion)(nil)
// newTestDriver returns a Driver wired to companion with no child process and a
// no-op container reset, so tests never touch the filesystem or spawn anything.
func newTestDriver(companion transport.Companion) *Driver {
d := &Driver{
companion: companion,
bundleID: "com.example.app",
output: &bytes.Buffer{},
doubleTapGapMilliseconds: DefaultDoubleTapGapMilliseconds,
screenWidth: 390,
screenHeight: 844,
}
d.resetContainer = func(context.Context) error { return nil }
d.reinstallApp = func(context.Context) error { return nil }
d.grantPaste = func(context.Context) error { return nil }
return d
}
func samplePNG(t *testing.T, width, height int) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, width, height))
img.Set(0, 0, color.RGBA{R: 1, A: 255})
var buffer bytes.Buffer
if err := png.Encode(&buffer, img); err != nil {
t.Fatalf("encode png: %v", err)
}
return buffer.Bytes()
}
func TestLaunchTerminatesFirstThenLaunches(t *testing.T) {
companion := &fakeCompanion{accessibilityJSON: "[]"}
d := newTestDriver(companion)
if err := d.Launch(context.Background(), "", false, nil); err != nil {
t.Fatalf("Launch: %v", err)
}
if companion.calls[0] != "terminate" {
t.Fatalf("first call = %q, want terminate", companion.calls[0])
}
if indexOf(companion.calls, "launch") < indexOf(companion.calls, "terminate") {
t.Fatalf("launch must follow terminate; got %v", companion.calls)
}
}
func TestLaunchGrantsPasteboardBeforeLaunch(t *testing.T) {
companion := &fakeCompanion{accessibilityJSON: "[]"}
d := newTestDriver(companion)
granted := false
d.grantPaste = func(context.Context) error { granted = true; return nil }
if err := d.Launch(context.Background(), "", false, nil); err != nil {
t.Fatalf("Launch: %v", err)
}
if !granted {
t.Fatal("Launch must grant pasteboard access so unicode input skips the permission prompt")
}
}
func TestLaunchContinuesWhenGrantFails(t *testing.T) {
companion := &fakeCompanion{accessibilityJSON: "[]"}
d := newTestDriver(companion)
d.grantPaste = func(context.Context) error { return errors.New("no privacy db") }
if err := d.Launch(context.Background(), "", false, nil); err != nil {
t.Fatalf("Launch must continue when the grant fails: %v", err)
}
if indexOf(companion.calls, "launch") < 0 {
t.Fatalf("app must still launch; got %v", companion.calls)
}
}
func TestLaunchClearStateReinstallsWithAppPath(t *testing.T) {
companion := &fakeCompanion{accessibilityJSON: "[]"}
d := newTestDriver(companion)
d.appPath = "/tmp/Sample.app"
reinstalls := 0
d.reinstallApp = func(context.Context) error { reinstalls++; return nil }
if err := d.Launch(context.Background(), "", true, nil); err != nil {
t.Fatalf("Launch: %v", err)
}
if reinstalls != 1 {
t.Fatalf("clear-state with app path must reinstall exactly once; got %d", reinstalls)
}
if indexOf(companion.calls, "launch") < indexOf(companion.calls, "terminate") {
t.Fatalf("launch must still follow terminate; got %v", companion.calls)
}
}
func TestLaunchClearStateFallbackWarnsOnce(t *testing.T) {
companion := &fakeCompanion{accessibilityJSON: "[]"}
output := &bytes.Buffer{}
d := newTestDriver(companion)
d.output = output
resets := 0
d.resetContainer = func(context.Context) error { resets++; return nil }
for i := 0; i < 2; i++ {
if err := d.Launch(context.Background(), "", true, nil); err != nil {
t.Fatalf("Launch %d: %v", i, err)
}
}
if resets != 2 {
t.Fatalf("resetContainer called %d times, want 2", resets)
}
warnings := strings.Count(output.String(), "resetting the data container only")
if warnings != 1 {
t.Fatalf("warning emitted %d times, want once", warnings)
}
for _, call := range companion.calls {
if call == "install" || call == "uninstall" {
t.Fatalf("fallback path must not install/uninstall; got %v", companion.calls)
}
}
}
func TestLaunchRejectsEnvironment(t *testing.T) {
d := newTestDriver(&fakeCompanion{accessibilityJSON: "[]"})
err := d.Launch(context.Background(), "", false, map[string]string{"K": "V"})
if err == nil || !strings.Contains(err.Error(), "environment") {
t.Fatalf("Launch with env: err = %v, want unsupported-environment error", err)
}
}
func TestSnapshotPairsHierarchyAndScreenshot(t *testing.T) {
companion := &fakeCompanion{
accessibilityJSON: "[]",
screenshotData: samplePNG(t, 390, 844),
}
d := newTestDriver(companion)
_, image, err := d.Snapshot(context.Background())
if err != nil {
t.Fatalf("Snapshot: %v", err)
}
if image.Width != 390 || image.Height != 844 {
t.Fatalf("image dims = %dx%d, want 390x844", image.Width, image.Height)
}
// The two captures run concurrently (they ride different transports on
// the hybrid path), so both must happen but in no particular order.
calls := companion.recorded()
if indexOf(calls, "accessibility") < 0 || indexOf(calls, "screenshot") < 0 {
t.Fatalf("snapshot must capture hierarchy and screenshot; got %v", calls)
}
}
// blockingScreenshotCompanion holds its Screenshot until proceed closes, so a
// test can keep the screenshot leg in flight while the hierarchy leg recovers.
type blockingScreenshotCompanion struct {
fakeCompanion
proceed chan struct{}
}
func (b *blockingScreenshotCompanion) Screenshot(ctx context.Context) ([]byte, string, error) {
<-b.proceed
return b.fakeCompanion.Screenshot(ctx)
}
func TestSnapshotRestartDuringScreenshotDoesNotRace(t *testing.T) {
// The hierarchy leg drops its connection, forcing withRecovery to restart
// while the screenshot goroutine is still in flight. The restart reassigns
// d.companion the way respawnAndRedial does; the goroutine must keep
// working through the transport it captured rather than racing the field.
first := &blockingScreenshotCompanion{
fakeCompanion: fakeCompanion{
accessibilityErr: status.Error(codes.Unavailable, "companion gone"),
screenshotData: samplePNG(t, 390, 844),
},
proceed: make(chan struct{}),
}
replacement := &fakeCompanion{
accessibilityJSON: "[]",
screenshotData: samplePNG(t, 390, 844),
}
d := newTestDriver(first)
d.restart = func(context.Context) error {
d.companion = replacement
close(first.proceed)
return nil
}
_, image, err := d.Snapshot(context.Background())
if err != nil {
t.Fatalf("Snapshot should recover: %v", err)
}
if image.Width != 390 || image.Height != 844 {
t.Fatalf("image dims = %dx%d, want 390x844", image.Width, image.Height)
}
}
func TestScreenshotRejectsNonPNG(t *testing.T) {
d := newTestDriver(&fakeCompanion{screenshotData: []byte("not a png")})
if _, err := d.Screenshot(context.Background()); err == nil {
t.Fatal("Screenshot of non-PNG should error")
}
}
func TestInputTextFastPathSkipsFieldResolution(t *testing.T) {
companion := &fakeCompanion{accessibilityJSON: "[]"}
d := newTestDriver(companion)
// "abc" is fully mappable and under the paste threshold, so the fast
// keyboard path runs and never reads the accessibility dump for a field
// target.
if err := d.InputText(context.Background(), "abc"); err != nil {
t.Fatalf("InputText: %v", err)
}
if indexOf(companion.calls, "hid") < 0 {
t.Fatalf("fast path must send HID; got %v", companion.calls)
}
if indexOf(companion.calls, "accessibility") >= 0 {
t.Fatalf("fast path must not resolve a field via accessibility; got %v", companion.calls)
}
}
func TestInputTextPasteboardResolvesFieldFromLastTap(t *testing.T) {
// The dump carries one editable field whose frame contains the last tap.
dump := `[{"type":"TextField","AXUniqueId":"field-1","frame":{"x":10,"y":100,"width":200,"height":40}}]`
companion := &fakeCompanion{accessibilityJSON: dump, screenshotData: samplePNG(t, 10, 10)}
d := newTestDriver(companion)
// Tap inside the field so lastTap lands on it.
if err := d.Tap(context.Background(), 50, 120); err != nil {
t.Fatalf("Tap: %v", err)
}
field := d.resolveInputField(context.Background())
if field.identifier != "field-1" {
t.Fatalf("resolved field id = %q, want field-1", field.identifier)
}
if field.centerX != 110 || field.centerY != 120 {
t.Fatalf("field center = (%v,%v), want (110,120)", field.centerX, field.centerY)
}
}
func TestResolveInputFieldEmptyWhenTapOutsideAnyField(t *testing.T) {
dump := `[{"type":"TextField","AXUniqueId":"field-1","frame":{"x":10,"y":100,"width":200,"height":40}}]`
d := newTestDriver(&fakeCompanion{accessibilityJSON: dump})
if err := d.Tap(context.Background(), 5, 5); err != nil {
t.Fatalf("Tap: %v", err)
}
if field := d.resolveInputField(context.Background()); field != (fieldTarget{}) {
t.Fatalf("field = %+v, want empty", field)
}
}
func TestSupervisionRestartsOnceThenSucceeds(t *testing.T) {
companion := &fakeCompanion{
accessibilityJSON: "[]",
hidErr: status.Error(codes.Unavailable, "connection refused"),
}
d := newTestDriver(companion)
restarts := 0
d.restart = func(context.Context) error {
restarts++
return nil
}
// First SendHID fails with Unavailable; the recovery restarts once and the
// retry succeeds (hidErr cleared itself).
if err := d.Tap(context.Background(), 10, 10); err != nil {
t.Fatalf("Tap should recover: %v", err)
}
if restarts != 1 {
t.Fatalf("restarts = %d, want exactly 1", restarts)
}
}
func TestSupervisionDoesNotRestartOnNormalError(t *testing.T) {
companion := &fakeCompanion{hidErr: errors.New("bad argument")}
d := newTestDriver(companion)
restarts := 0
d.restart = func(context.Context) error { restarts++; return nil }
if err := d.Tap(context.Background(), 10, 10); err == nil {
t.Fatal("non-connection error should surface")
}
if restarts != 0 {
t.Fatalf("restarts = %d, want 0 on a normal error", restarts)
}
}
func TestSupervisionBudgetResetsBetweenIncidents(t *testing.T) {
companion := &fakeCompanion{accessibilityJSON: "[]"}
d := newTestDriver(companion)
restarts := 0
d.restart = func(context.Context) error { restarts++; return nil }
companion.hidErr = status.Error(codes.Unavailable, "drop one")
if err := d.Tap(context.Background(), 1, 1); err != nil {
t.Fatalf("first incident: %v", err)
}
companion.hidErr = status.Error(codes.Unavailable, "drop two")
if err := d.Tap(context.Background(), 2, 2); err != nil {
t.Fatalf("second incident: %v", err)
}
if restarts != 2 {
t.Fatalf("restarts = %d, want 2 (one per incident)", restarts)
}
}
func TestPressKeyEnterSupportedOthersUnsupported(t *testing.T) {
d := newTestDriver(&fakeCompanion{})
if err := d.PressKey(context.Background(), "enter"); err != nil {
t.Fatalf("PressKey enter: %v", err)
}
if err := d.PressKey(context.Background(), "return"); err != nil {
t.Fatalf("PressKey return: %v", err)
}
for _, key := range []string{"back", "home", "tab"} {
if err := d.PressKey(context.Background(), key); err == nil {
t.Fatalf("PressKey %q should be unsupported", key)
}
}
}
func TestWaitForIdleSettlesOnStableTree(t *testing.T) {
companion := &fakeCompanion{accessibilityJSON: "[]"}
d := newTestDriver(companion)
// A fake clock advances time only on Sleep, so the settle poll runs to its
// cap instantly instead of taking the real two seconds.
d.idleClock = &fakeClock{}
if err := d.WaitForIdle(context.Background(), time.Second); err != nil {
t.Fatalf("WaitForIdle: %v", err)
}
if indexOf(companion.calls, "accessibility") < 0 {
t.Fatalf("WaitForIdle must poll the hierarchy; got %v", companion.calls)
}
}
func TestForegroundAppPrefersAppUnderTest(t *testing.T) {
companion := &fakeCompanion{apps: []transport.InstalledApp{
{BundleID: "com.example.app", ProcessState: transport.ProcessStateRunning, InstallType: "user"},
{BundleID: "com.other.app", ProcessState: transport.ProcessStateRunning, InstallType: "user"},
}}
d := newTestDriver(companion)
got, err := d.ForegroundApp(context.Background())
if err != nil {
t.Fatalf("ForegroundApp: %v", err)
}
if got != "com.example.app" {
t.Fatalf("ForegroundApp = %q, want com.example.app", got)
}
}
func TestForegroundAppFallsBackToOtherRunningUserApp(t *testing.T) {
companion := &fakeCompanion{apps: []transport.InstalledApp{
{BundleID: "com.example.app", ProcessState: transport.ProcessStateNotRunning, InstallType: "user"},
{BundleID: "com.other.app", ProcessState: transport.ProcessStateRunning, InstallType: "user"},
}}
d := newTestDriver(companion)
got, err := d.ForegroundApp(context.Background())
if err != nil {
t.Fatalf("ForegroundApp: %v", err)
}
if got != "com.other.app" {
t.Fatalf("ForegroundApp = %q, want com.other.app", got)
}
}
func TestHealthReportsIOS(t *testing.T) {
d := newTestDriver(&fakeCompanion{})
health, err := d.Health(context.Background())
if err != nil {
t.Fatalf("Health: %v", err)
}
if !health.Ready || health.Platform != "ios" {
t.Fatalf("Health = %+v, want ready ios", health)
}
}
// The driver must satisfy DeviceDriver, ForegroundChecker, and TextReplacer
// (its InputText clears the field then types, so the runner skips its
// blackout-fragile pre-erase), but must NOT satisfy FocusedWindowChecker.
func TestCapabilityAssertions(t *testing.T) {
var instance any = newTestDriver(&fakeCompanion{})
if _, ok := instance.(driver.DeviceDriver); !ok {
t.Fatal("Driver must implement DeviceDriver")
}
if _, ok := instance.(driver.ForegroundChecker); !ok {
t.Fatal("Driver must implement ForegroundChecker")
}
replacer, ok := instance.(driver.TextReplacer)
if !ok {
t.Fatal("Driver must implement TextReplacer")
}
if !replacer.ReplacesTextOnInput() {
t.Fatal("ReplacesTextOnInput must report true")
}
if _, ok := instance.(driver.FocusedWindowChecker); ok {
t.Fatal("Driver must NOT implement FocusedWindowChecker")
}
}
func indexOf(slice []string, value string) int {
for i, item := range slice {
if item == value {
return i
}
}
return -1
}
// TestNewChildOutlivesStartup proves the companion child is spawned under the
// driver-lifetime context, not the startup-scoped one: a startup context that
// is canceled when New returns would SIGTERM the child mid-run.
func TestNewChildOutlivesStartup(t *testing.T) {
// The legacy-only path keeps this test focused on the companion child;
// the hybrid default would also demand runner assets.
t.Setenv("SANDERLING_SIMULATOR_COMPANION", "legacy")
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
go func() {
for {
connection, acceptErr := listener.Accept()
if acceptErr != nil {
return
}
_ = connection.Close()
}
}()
var spawnContext context.Context
options := Options{
UniqueDeviceIdentifier: "FAKE-UDID",
pickAddress: func() (string, error) { return listener.Addr().String(), nil },
spawnChild: func(ctx context.Context, _ string) (*exec.Cmd, error) {
spawnContext = ctx
return &exec.Cmd{}, nil
},
dialCompanion: func(string) (transport.Companion, error) {
return &fakeCompanion{accessibilityJSON: "[]"}, nil
},
}
d, err := New(context.Background(), options)
if err != nil {
t.Fatal(err)
}
select {
case <-spawnContext.Done():
t.Fatal("spawn context canceled after New returned; child would receive SIGTERM mid-run")
default:
}
d.Close()
select {
case <-spawnContext.Done():
default:
t.Fatal("spawn context still alive after Close; child lifetime leaks")
}
}
// fakeTextEditingCompanion extends fakeCompanion with the optional TextEditor
// capability so routing through the native text path is testable.
type fakeTextEditingCompanion struct {
fakeCompanion
inputTexts []string
eraseCounts []int
pressedKeys []string
}
func (f *fakeTextEditingCompanion) InputText(_ context.Context, text string) error {
f.record("inputtext")
f.inputTexts = append(f.inputTexts, text)
return nil
}
func (f *fakeTextEditingCompanion) EraseText(_ context.Context, characterCount int) error {
f.record("erasetext")
f.eraseCounts = append(f.eraseCounts, characterCount)
return nil
}
func (f *fakeTextEditingCompanion) PressKey(_ context.Context, key string) error {
f.record("presskey")
f.pressedKeys = append(f.pressedKeys, key)
return nil
}
var _ transport.TextEditor = (*fakeTextEditingCompanion)(nil)
func TestInputTextRoutesThroughTextEditor(t *testing.T) {
companion := &fakeTextEditingCompanion{}
d := newTestDriver(companion)
if err := d.InputText(context.Background(), "héllo 🌟"); err != nil {
t.Fatal(err)
}
if len(companion.inputTexts) != 1 || companion.inputTexts[0] != "héllo 🌟" {
t.Fatalf("inputTexts = %v, want the typed text once", companion.inputTexts)
}
for _, call := range companion.calls {
if call == "hid" {
t.Fatal("text editor path must not compose HID streams")
}
}
}
func TestEraseTextRoutesThroughTextEditor(t *testing.T) {
companion := &fakeTextEditingCompanion{}
d := newTestDriver(companion)
if err := d.EraseText(context.Background(), 7); err != nil {
t.Fatal(err)
}
if len(companion.eraseCounts) != 1 || companion.eraseCounts[0] != 7 {
t.Fatalf("eraseCounts = %v, want [7]", companion.eraseCounts)
}
}
func TestPressKeyRoutesThroughTextEditor(t *testing.T) {
companion := &fakeTextEditingCompanion{}
d := newTestDriver(companion)
if err := d.PressKey(context.Background(), "enter"); err != nil {
t.Fatal(err)
}
if len(companion.pressedKeys) != 1 || companion.pressedKeys[0] != "enter" {
t.Fatalf("pressedKeys = %v, want [enter]", companion.pressedKeys)
}
for _, call := range companion.calls {
if call == "hid" {
t.Fatal("text editor path must not compose HID streams")
}
}
}
func TestIsConnectionErrorRecognizesUnavailableSentinel(t *testing.T) {
wrapped := errors.Join(errors.New("dial tcp: connection refused"), transport.ErrCompanionUnavailable)
if !isConnectionError(wrapped) {
t.Fatal("wrapped ErrCompanionUnavailable must count as a connection error")
}
if isConnectionError(errors.New("ordinary failure")) {
t.Fatal("ordinary errors must not count as connection errors")
}
}
// fakeRunnerCompanion stands in for the in-simulator runner half of the hybrid:
// it serves snapshots and native typing.
type fakeRunnerCompanion struct {
fakeCompanion
typed []struct {
text string
replace bool
}
}
func (f *fakeRunnerCompanion) TypeText(_ context.Context, text string, replace bool) error {
f.record("typetext")
f.typed = append(f.typed, struct {
text string
replace bool
}{text, replace})
return nil
}
var _ transport.TextTyper = (*fakeRunnerCompanion)(nil)
func newHybridTestDriver(legacy *fakeCompanion, runner *fakeRunnerCompanion) *Driver {
d := newTestDriver(legacy)
d.hybrid = true
d.runnerClient = runner
return d
}
func TestHybridInputTextClearsViaHIDThenTypes(t *testing.T) {
legacy := &fakeCompanion{accessibilityJSON: "[]"}
runner := &fakeRunnerCompanion{}
d := newHybridTestDriver(legacy, runner)
if err := d.InputText(context.Background(), "héllo 🌟"); err != nil {
t.Fatal(err)
}
if len(legacy.calls) != 1 || legacy.calls[0] != "hid" {
t.Fatalf("legacy calls = %v, want exactly the clear chord", legacy.calls)
}
if len(runner.typed) != 1 || runner.typed[0].text != "héllo 🌟" || runner.typed[0].replace {
t.Fatalf("typed = %+v, want the text appended without replace", runner.typed)
}
}
func TestHybridSnapshotsComeFromRunner(t *testing.T) {
legacy := &fakeCompanion{accessibilityJSON: `[{"type":"Application","frame":{"x":0,"y":0,"width":1,"height":1},"enabled":true}]`}
runner := &fakeRunnerCompanion{}
runner.accessibilityJSON = `[{"type":"Button","AXUniqueId":"FromRunner","frame":{"x":0,"y":0,"width":10,"height":10},"enabled":true}]`
d := newHybridTestDriver(legacy, runner)
hierarchy, err := d.Hierarchy(context.Background())
if err != nil {
t.Fatal(err)
}
if !strings.Contains(hierarchy, "FromRunner") {
t.Fatalf("hierarchy = %s, want runner content", hierarchy)
}
for _, call := range legacy.calls {
if call == "accessibility" {
t.Fatal("hybrid must not read snapshots from the legacy companion")
}
}
}
func TestHybridLaunchSkipsPasteGrant(t *testing.T) {
legacy := &fakeCompanion{accessibilityJSON: "[]"}
runner := &fakeRunnerCompanion{}
d := newHybridTestDriver(legacy, runner)
granted := 0
d.grantPaste = func(context.Context) error { granted++; return nil }
if err := d.Launch(context.Background(), "", false, nil); err != nil {
t.Fatal(err)
}
if granted != 0 {
t.Fatalf("grantPaste called %d times, want 0 on the hybrid path", granted)
}
}
func TestHybridGesturesStayOnLegacyHID(t *testing.T) {
legacy := &fakeCompanion{accessibilityJSON: "[]"}
runner := &fakeRunnerCompanion{}
d := newHybridTestDriver(legacy, runner)
if err := d.DoubleTap(context.Background(), 10, 20); err != nil {
t.Fatal(err)
}
if len(legacy.calls) != 1 || legacy.calls[0] != "hid" {
t.Fatalf("legacy calls = %v, want the double-tap HID stream", legacy.calls)
}
for _, call := range runner.calls {
if call == "hid" {
t.Fatal("runner must not receive gesture streams")
}
}
}
func TestHybridRunnerErrorTriggersRestart(t *testing.T) {
legacy := &fakeCompanion{accessibilityJSON: "[]"}
runner := &fakeRunnerCompanion{}
runner.accessibilityErr = fmt.Errorf("read snapshot: %w", transport.ErrCompanionUnavailable)
d := newHybridTestDriver(legacy, runner)
restarts := 0
d.restart = func(context.Context) error {
restarts++
runner.accessibilityErr = nil
return nil
}
if _, err := d.Hierarchy(context.Background()); err != nil {
t.Fatal(err)
}
if restarts != 1 {
t.Fatalf("restarts = %d, want 1", restarts)
}
}
func TestBindTestRunPortSubstitutesPlaceholder(t *testing.T) {
directory := t.TempDir()
source := filepath.Join(directory, "runner.xctestrun")
if err := os.WriteFile(source, []byte("<string>__COMPANION_PORT__</string>"), 0o644); err != nil {
t.Fatal(err)
}
bound, err := bindTestRunPort(source, "27999")
if err != nil {
t.Fatal(err)
}
if filepath.Dir(bound) != directory {
t.Fatalf("bound copy %s must sit next to the original", bound)
}
content, err := os.ReadFile(bound)
if err != nil {
t.Fatal(err)
}
if string(content) != "<string>27999</string>" {
t.Fatalf("bound content = %s", content)
}
}
func TestBindTestRunPortRejectsMissingPlaceholder(t *testing.T) {
directory := t.TempDir()
source := filepath.Join(directory, "runner.xctestrun")
if err := os.WriteFile(source, []byte("<string>fixed</string>"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := bindTestRunPort(source, "27999"); err == nil {
t.Fatal("expected an error for a configuration without the placeholder")
}
}
func TestHybridMappableTextRidesOneHIDStream(t *testing.T) {
legacy := &fakeCompanion{accessibilityJSON: "[]"}
runner := &fakeRunnerCompanion{}
d := newHybridTestDriver(legacy, runner)
if err := d.InputText(context.Background(), "Travel 42"); err != nil {
t.Fatal(err)
}
if len(legacy.recorded()) != 1 || legacy.recorded()[0] != "hid" {
t.Fatalf("legacy calls = %v, want one combined chord-and-keystrokes stream", legacy.recorded())
}
if len(runner.typed) != 0 {
t.Fatalf("typed = %+v, want no native typing for mappable text", runner.typed)
}
}
func TestHybridUnicodeWaitsForClearedFieldBeforeTyping(t *testing.T) {
legacy := &fakeCompanion{accessibilityJSON: "[]"}
runner := &fakeRunnerCompanion{}
// The focused field still shows old content on the first read and is
// empty on the second; typing must come after the cleared read.
first := `[{"type":"TextField","AXUniqueId":"F","AXValue":"old","frame":{"x":0,"y":0,"width":100,"height":40},"enabled":true}]`
second := `[{"type":"TextField","AXUniqueId":"F","AXValue":"","frame":{"x":0,"y":0,"width":100,"height":40},"enabled":true}]`
reads := 0
d := newHybridTestDriver(legacy, runner)
d.mu.Lock()
d.lastTap.x, d.lastTap.y, d.lastTap.set = 50, 20, true
d.mu.Unlock()
// Swap the dump after the first read through a wrapper companion.
wrapped := &sequencedDumpCompanion{fakeRunnerCompanion: runner, dumps: []string{first, second}, reads: &reads}
d.runnerClient = wrapped
if err := d.InputText(context.Background(), "héllo 🌟"); err != nil {
t.Fatal(err)
}
if reads < 2 {
t.Fatalf("reads = %d, want at least 2 (poll until cleared)", reads)
}
if len(wrapped.typed) != 1 || wrapped.typed[0].text != "héllo 🌟" || wrapped.typed[0].replace {
t.Fatalf("typed = %+v", wrapped.typed)
}
}
// sequencedDumpCompanion serves scripted dumps in order, repeating the last.
type sequencedDumpCompanion struct {
*fakeRunnerCompanion
dumps []string
reads *int
}
func (s *sequencedDumpCompanion) AccessibilityInfo(context.Context) (string, error) {
index := *s.reads
if index >= len(s.dumps) {
index = len(s.dumps) - 1
}
*s.reads++
return s.dumps[index], nil
}
@@ -0,0 +1,171 @@
package ioscompanion
import (
"encoding/json"
"math"
"strconv"
)
// rawFrame is the simulator companion frame, in points, with float coordinates.
type rawFrame struct {
X float64 `json:"x"`
Y float64 `json:"y"`
Width float64 `json:"width"`
Height float64 `json:"height"`
}
// emptyFieldValueSentinel is what the accessibility bridge reports as the
// AXValue of an empty editable field. It is bridge state, not app content, so
// it maps to an empty value rather than surfacing as literal field text.
const emptyFieldValueSentinel = "Invalid"
// dumpIsCollapsed reports whether a flat describe-all dump carries no real UI
// content: it is empty or holds only the application shell. The accessibility
// bridge briefly returns this state during cold start and screen transitions
// before the real tree reappears.
func dumpIsCollapsed(dump []byte) bool {
elements := decodeDump(dump)
for _, element := range elements {
if element.Type != "" && element.Type != "Application" {
return false
}
}
return true
}
// rawElement is one entry in the flat describe-all dump returned by the
// simulator companion. Only the fields the mapper consumes are declared;
// unknown fields are ignored.
type rawElement struct {
Frame rawFrame `json:"frame"`
AXUniqueID *string `json:"AXUniqueId"`
AXLabel *string `json:"AXLabel"`
AXValue *string `json:"AXValue"`
Type string `json:"type"`
Enabled bool `json:"enabled"`
}
// treeNode mirrors the TreeNode JSON the hierarchy package parses. Bool fields
// are pointers so absent ones marshal to null and stay absent for the consumer.
type treeNode struct {
Attributes map[string]string `json:"attributes"`
Children []treeNode `json:"children,omitempty"`
Clickable *bool `json:"clickable,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
Editable *bool `json:"editable,omitempty"`
}
// MapHierarchy converts a flat describe-all dump from the simulator companion
// into the TreeNode JSON consumed by hierarchy.Parse. The result is a single
// synthesized root covering the whole screen with every dump element as a
// direct child. Coordinates are points throughout; nothing is scaled.
//
// The function is total: malformed input yields an empty-but-valid root rather
// than an error, and individual malformed elements are skipped.
func MapHierarchy(dump []byte, screenWidth, screenHeight int) ([]byte, error) {
root := treeNode{
Attributes: map[string]string{
"bounds": boundsString(0, 0, screenWidth, screenHeight),
"class": "Window",
},
}
// Decode element-by-element so a single malformed entry (a bad frame, an
// out-of-range number) is skipped rather than discarding the whole dump.
var rawElements []json.RawMessage
if len(dump) > 0 {
_ = json.Unmarshal(dump, &rawElements)
}
for _, raw := range rawElements {
var element rawElement
if err := json.Unmarshal(raw, &element); err != nil {
continue
}
if child, ok := mapElement(&element); ok {
root.Children = append(root.Children, child)
}
}
return json.Marshal(root)
}
func mapElement(element *rawElement) (treeNode, bool) {
if element.Type == "" {
return treeNode{}, false
}
frame := element.Frame
if !finite(frame.X) || !finite(frame.Y) || !finite(frame.Width) || !finite(frame.Height) {
return treeNode{}, false
}
left := roundCoord(frame.X)
top := roundCoord(frame.Y)
attributes := map[string]string{
"bounds": boundsString(left, top, left+roundCoord(frame.Width), top+roundCoord(frame.Height)),
"class": element.Type,
}
if id := stringValue(element.AXUniqueID); id != "" {
attributes["identifier"] = id
}
value := stringValue(element.AXValue)
if value == emptyFieldValueSentinel {
// An empty editable field reads as this sentinel through the bridge;
// it is not app content, so treat the field as empty.
value = ""
}
label := stringValue(element.AXLabel)
editable := isEditable(element.Type)
if value != "" {
attributes["text"] = value
if label != "" {
attributes["accessibilityText"] = label
}
} else if editable && label != "" {
// An empty text field surfaces its placeholder as the AXLabel.
attributes["hintText"] = label
} else if label != "" {
attributes["accessibilityText"] = label
}
enabled := element.Enabled
node := treeNode{Attributes: attributes, Enabled: &enabled}
if editable {
yes := true
node.Editable = &yes
}
if element.Type == "Button" {
yes := true
node.Clickable = &yes
}
return node, true
}
func isEditable(elementType string) bool {
return elementType == "TextArea" || elementType == "TextField"
}
func stringValue(pointer *string) string {
if pointer == nil {
return ""
}
return *pointer
}
func finite(value float64) bool {
return !math.IsNaN(value) && !math.IsInf(value, 0)
}
func roundCoord(value float64) int {
return int(math.Round(value))
}
func boundsString(left, top, right, bottom int) string {
return "[" + strconv.Itoa(left) + "," + strconv.Itoa(top) + "][" +
strconv.Itoa(right) + "," + strconv.Itoa(bottom) + "]"
}
@@ -0,0 +1,260 @@
package ioscompanion
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/priyanshujain/sanderling/internal/hierarchy"
)
func mapAndParse(t *testing.T, dump []byte, width, height int) *hierarchy.Tree {
t.Helper()
treeJSON, err := MapHierarchy(dump, width, height)
if err != nil {
t.Fatalf("MapHierarchy: %v", err)
}
tree, err := hierarchy.Parse(string(treeJSON))
if err != nil {
t.Fatalf("hierarchy.Parse: %v", err)
}
return tree
}
func readDump(t *testing.T, name string) []byte {
t.Helper()
data, err := os.ReadFile(filepath.Join("testdata", name))
if err != nil {
t.Fatalf("read %s: %v", name, err)
}
return data
}
func TestGoldenLoginResolvesIdentifier(t *testing.T) {
tree := mapAndParse(t, readDump(t, "login-describe.json"), 402, 874)
element := tree.Find("id:LoginEmail")
if element == nil {
t.Fatal("id:LoginEmail did not resolve")
}
if element.ResourceID != "LoginEmail" {
t.Fatalf("ResourceID = %q, want LoginEmail", element.ResourceID)
}
if element.Class != "TextArea" {
t.Fatalf("Class = %q, want TextArea", element.Class)
}
// LoginEmail is an empty field, so its placeholder is the hintText.
if element.Attributes["hintText"] != "Email" {
t.Fatalf("hintText = %q, want Email", element.Attributes["hintText"])
}
if !element.Editable {
t.Fatal("LoginEmail should be editable")
}
wantBounds := hierarchy.Bounds{Left: 34, Top: 125, Right: 368, Bottom: 173}
if element.Bounds != wantBounds {
t.Fatalf("Bounds = %+v, want %+v", element.Bounds, wantBounds)
}
}
func TestGoldenScopedQueryUsesSpatialFallback(t *testing.T) {
tree := mapAndParse(t, readDump(t, "accounts-describe.json"), 402, 874)
// The Application node spans the full screen but the flat tree gives it no
// descendants. A scoped query for content inside it must resolve through the
// hierarchy package's spatial-containment fallback.
container := tree.FindNode("class:Application")
if container == nil {
t.Fatal("class:Application did not resolve")
}
if len(container.Children) != 0 {
t.Fatalf("expected a flat tree with no Application descendants, got %d", len(container.Children))
}
scoped := container.Find("desc:Accounts")
if scoped == nil {
t.Fatal("scoped desc:Accounts did not resolve via spatial fallback")
}
if scoped.Description != "Accounts" {
t.Fatalf("scoped Description = %q, want Accounts", scoped.Description)
}
// Smallest-area ranking should put the compact label ahead of any larger
// spatially-contained match.
scopedButton := container.Find("id:AddAccountButton")
if scopedButton == nil {
t.Fatal("scoped id:AddAccountButton did not resolve via spatial fallback")
}
if scopedButton.ResourceID != "AddAccountButton" {
t.Fatalf("scoped ResourceID = %q, want AddAccountButton", scopedButton.ResourceID)
}
}
func parseSingle(t *testing.T, dump string) *hierarchy.Element {
t.Helper()
tree := mapAndParse(t, []byte(dump), 400, 800)
if len(tree.Elements) < 2 {
t.Fatalf("expected root plus one child, got %d elements", len(tree.Elements))
}
// Element 0 is the synthesized root; element 1 is the mapped child.
return tree.Elements[1]
}
func TestPlaceholderMapsToHintText(t *testing.T) {
dump := `[{"type":"TextField","frame":{"x":10,"y":20,"width":100,"height":40},
"AXUniqueId":"Search","AXLabel":"Search accounts","AXValue":null,"enabled":true}]`
element := parseSingle(t, dump)
if element.Attributes["hintText"] != "Search accounts" {
t.Fatalf("hintText = %q, want Search accounts", element.Attributes["hintText"])
}
if element.Attributes["accessibilityText"] != "" {
t.Fatalf("accessibilityText = %q, want empty", element.Attributes["accessibilityText"])
}
if element.Text != "" {
t.Fatalf("text = %q, want empty", element.Text)
}
}
func TestNonEmptyValueMapsToText(t *testing.T) {
dump := `[{"type":"TextField","frame":{"x":0,"y":0,"width":10,"height":10},
"AXUniqueId":"Search","AXLabel":"Search accounts","AXValue":"groceries","enabled":true}]`
element := parseSingle(t, dump)
if element.Text != "groceries" {
t.Fatalf("text = %q, want groceries", element.Text)
}
// With a value present, the label is a real accessibility label, not a hint.
if element.Attributes["accessibilityText"] != "Search accounts" {
t.Fatalf("accessibilityText = %q, want Search accounts", element.Attributes["accessibilityText"])
}
if element.Attributes["hintText"] != "" {
t.Fatalf("hintText = %q, want empty", element.Attributes["hintText"])
}
}
func TestEditableAndClickableFlags(t *testing.T) {
cases := []struct {
elementType string
wantEditable bool
wantClickable bool
}{
{"TextField", true, false},
{"TextArea", true, false},
{"Button", false, true},
{"StaticText", false, false},
}
for _, testCase := range cases {
dump := `[{"type":"` + testCase.elementType + `","frame":{"x":0,"y":0,"width":10,"height":10},"enabled":true}]`
element := parseSingle(t, dump)
if element.Editable != testCase.wantEditable {
t.Errorf("%s editable = %v, want %v", testCase.elementType, element.Editable, testCase.wantEditable)
}
if element.Clickable != testCase.wantClickable {
t.Errorf("%s clickable = %v, want %v", testCase.elementType, element.Clickable, testCase.wantClickable)
}
}
}
func TestEnabledMapsToTopLevelBool(t *testing.T) {
dump := `[{"type":"Button","frame":{"x":0,"y":0,"width":10,"height":10},"enabled":false}]`
element := parseSingle(t, dump)
if element.Enabled {
t.Fatal("element should be disabled")
}
if element.Attributes["enabled"] != "false" {
t.Fatalf("enabled attr = %q, want false", element.Attributes["enabled"])
}
}
func TestBoundsArithmetic(t *testing.T) {
dump := `[{"type":"Button","frame":{"x":34,"y":125.33333,"width":334,"height":48.00001},"enabled":true}]`
element := parseSingle(t, dump)
want := hierarchy.Bounds{Left: 34, Top: 125, Right: 368, Bottom: 173}
if element.Bounds != want {
t.Fatalf("Bounds = %+v, want %+v", element.Bounds, want)
}
}
func TestMalformedElementsSkipped(t *testing.T) {
// First element has no type and must be skipped; second has a NaN frame and
// must be skipped; third is valid.
dump := `[
{"frame":{"x":0,"y":0,"width":10,"height":10},"enabled":true},
{"type":"Button","frame":{"x":0,"y":0,"width":1e999,"height":10},"enabled":true},
{"type":"Button","frame":{"x":0,"y":0,"width":10,"height":10},"AXUniqueId":"OK","enabled":true}
]`
tree := mapAndParse(t, []byte(dump), 400, 800)
// Root plus exactly one valid child.
if len(tree.Elements) != 2 {
t.Fatalf("expected 2 elements (root + 1 valid), got %d", len(tree.Elements))
}
if tree.Find("id:OK") == nil {
t.Fatal("valid element OK should resolve")
}
}
func TestEmptyAndGarbageInputAreTotal(t *testing.T) {
for _, dump := range [][]byte{nil, {}, []byte("not json"), []byte("{}"), []byte("[]")} {
treeJSON, err := MapHierarchy(dump, 100, 200)
if err != nil {
t.Fatalf("MapHierarchy(%q) error: %v", dump, err)
}
var node treeNode
if err := json.Unmarshal(treeJSON, &node); err != nil {
t.Fatalf("result not valid TreeNode JSON for %q: %v", dump, err)
}
if node.Attributes["bounds"] != "[0,0][100,200]" {
t.Fatalf("root bounds = %q, want [0,0][100,200]", node.Attributes["bounds"])
}
}
}
func TestRootIsFlatWithAllChildren(t *testing.T) {
tree := mapAndParse(t, readDump(t, "login-describe.json"), 402, 874)
if tree.Root == nil {
t.Fatal("nil root")
}
// The login dump has 10 elements; all map to direct children of the root.
if len(tree.Root.Children) != 10 {
t.Fatalf("root children = %d, want 10", len(tree.Root.Children))
}
wantRoot := hierarchy.Bounds{Left: 0, Top: 0, Right: 402, Bottom: 874}
if tree.Root.Bounds != wantRoot {
t.Fatalf("root bounds = %+v, want %+v", tree.Root.Bounds, wantRoot)
}
}
func TestMapHierarchySentinelValueMapsAsEmpty(t *testing.T) {
dump := `[{"type":"TextField","AXUniqueId":"F","AXLabel":"Email","AXValue":"Invalid","frame":{"x":0,"y":0,"width":10,"height":10},"enabled":true}]`
mapped, err := MapHierarchy([]byte(dump), 100, 100)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(mapped), "Invalid") {
t.Fatalf("sentinel leaked into mapped tree: %s", mapped)
}
if !strings.Contains(string(mapped), "hintText") {
t.Fatalf("empty editable field should map label to hintText: %s", mapped)
}
}
func TestDumpIsCollapsed(t *testing.T) {
cases := []struct {
name string
dump string
want bool
}{
{name: "empty array", dump: `[]`, want: true},
{name: "application only", dump: `[{"type":"Application","frame":{"x":0,"y":0,"width":390,"height":844}}]`, want: true},
{name: "malformed", dump: `nope`, want: true},
{name: "real screen", dump: `[{"type":"Application"},{"type":"TextArea","AXUniqueId":"LoginEmail","frame":{"x":0,"y":0,"width":1,"height":1}}]`, want: false},
{name: "single button", dump: `[{"type":"Button","frame":{"x":0,"y":0,"width":1,"height":1}}]`, want: false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := dumpIsCollapsed([]byte(c.dump)); got != c.want {
t.Fatalf("got %v, want %v", got, c.want)
}
})
}
}
+434
View File
@@ -0,0 +1,434 @@
// Package ioscompanion drives an iOS simulator through the native simulator
// companion. This file composes text input and gestures into HID streams and
// implements the pasteboard fallback for text that the hardware keyboard
// cannot type (anything outside the mappable rune set, such as accented
// letters or emoji).
package ioscompanion
import (
"context"
"encoding/json"
"fmt"
"os/exec"
"strings"
"time"
"github.com/priyanshujain/sanderling/internal/driver/ioscompanion/transport"
)
// DefaultDoubleTapGapMilliseconds is a sensible inter-tap gap for a synthesized
// double tap. The driver owns the real default; gesture composers take the gap
// as a parameter so the value stays configurable.
const DefaultDoubleTapGapMilliseconds = 70
// pasteVerifyTimeout bounds the whole paste-and-verify loop. Dismissing the
// permission dialog blacks out the accessibility bridge for around 2.5s (the
// dump collapses to the root element), and the pasted value only becomes
// readable once it recovers, so the budget has to outlast that blackout.
const pasteVerifyTimeout = 8 * time.Second
// pastePoll is the interval between describe-all reads while verifying a paste.
const pastePoll = 250 * time.Millisecond
// dialogSettle is the pause after tapping the dialog's allow button and after
// refocusing the field, before the paste chord is resent.
const dialogSettle = 200 * time.Millisecond
// runner abstracts the simulator-companion side effects InputText needs so the
// decision logic stays testable without a live device. The driver supplies a
// real implementation; tests supply a fake.
type runner interface {
// setPasteboard places text on the simulator pasteboard.
setPasteboard(ctx context.Context, text string) error
// sendHID sends one HID stream to the companion.
sendHID(ctx context.Context, events ...transport.HIDEvent) error
// describeAll returns the flat describe-all accessibility dump.
describeAll(ctx context.Context) ([]byte, error)
// sleep waits, respecting context cancellation.
sleep(ctx context.Context, duration time.Duration) error
}
// simctlRunner is the production runner. It shells out to simctl for the
// pasteboard and uses the transport companion for everything else.
type simctlRunner struct {
companion transport.Companion
udid string
}
func (r simctlRunner) setPasteboard(ctx context.Context, text string) error {
command := exec.CommandContext(ctx, "xcrun", "simctl", "pbcopy", r.udid)
command.Stdin = strings.NewReader(text)
return command.Run()
}
func (r simctlRunner) sendHID(ctx context.Context, events ...transport.HIDEvent) error {
return r.companion.SendHID(ctx, events...)
}
func (r simctlRunner) describeAll(ctx context.Context) ([]byte, error) {
info, err := r.companion.AccessibilityInfo(ctx)
if err != nil {
return nil, err
}
return []byte(info), nil
}
func (r simctlRunner) sleep(ctx context.Context, duration time.Duration) error {
timer := time.NewTimer(duration)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
// fieldTarget identifies the focused field for the pasteboard path: its
// AXUniqueId (to confirm the paste landed) and its on-screen center (to refocus
// after dismissing the permission dialog).
type fieldTarget struct {
identifier string
centerX float64
centerY float64
}
// usesPasteboard reports whether text takes the pasteboard path. Only
// unmappable runes force it: on this OS generation every external pasteboard
// write re-triggers the paste-permission dialog and dismissing it blacks out
// the accessibility bridge for seconds, so the hardware keyboard stays the
// default for everything it can express.
func usesPasteboard(text string) bool {
_, skipped := typeString(text)
return len(skipped) > 0
}
// inputText replaces the focused field's content with text. It selects any
// existing content and deletes it first, so the result is the typed text alone
// regardless of what the field held. Replacing (rather than relying on the
// runner's pre-erase) keeps input correct even when the accessibility bridge is
// momentarily collapsed and the runner cannot read the field's length. Mappable
// text goes through the hardware keyboard in one HID stream; anything else falls
// back to the pasteboard. The field target is only consulted on the pasteboard
// path.
func inputText(ctx context.Context, run runner, text string, field fieldTarget) error {
if !usesPasteboard(text) {
events := append(clearFieldEvents(), keyPressEvents(typeStringPresses(text))...)
return run.sendHID(ctx, events...)
}
if err := run.sendHID(ctx, clearFieldEvents()...); err != nil {
return fmt.Errorf("clear field: %w", err)
}
return pasteText(ctx, run, text, field)
}
// typeStringPresses is typeString's presses, dropping the skipped runes (the
// caller already decided this text is fully mappable).
func typeStringPresses(text string) []KeyPress {
presses, _ := typeString(text)
return presses
}
// selectionApplyDelayMilliseconds is the in-stream pause between the
// select-all chord and the deleting backspace. The chord's selection applies
// asynchronously in the app; a backspace fired in the same instant deletes
// one character at the cursor instead of the selection, which on a full field
// silently turns replace into append. Long content needs the most time, and
// this pause covers it with margin.
const selectionApplyDelayMilliseconds = 150
// deleteApplyDelayMilliseconds is the in-stream pause after the deleting
// backspace, so following keystrokes land in the emptied field.
const deleteApplyDelayMilliseconds = 40
// clearFieldEvents selects the whole field (command+A), waits for the
// selection to apply, deletes it, and waits for the delete to apply, so a
// following type or paste lands in an empty field. On an already-empty field
// the select selects nothing and the delete is a no-op.
func clearFieldEvents() []transport.HIDEvent {
events := append(selectAllChordEvents(), transport.Delay(selectionApplyDelayMilliseconds))
events = append(events, keyPressEvents(backspaces(1))...)
return append(events, transport.Delay(deleteApplyDelayMilliseconds))
}
// pasteText copies the full text to the pasteboard, sends the paste chord
// once, and polls until the field reflects the text. The chord is re-sent ONLY
// after dismissing a permission dialog (the dialog swallowed that paste);
// re-sending it on a slow render would paste the text twice. When the field
// cannot be verified (no identifier resolved), one chord plus a settle is the
// best available behavior.
func pasteText(ctx context.Context, run runner, text string, field fieldTarget) error {
if err := run.setPasteboard(ctx, text); err != nil {
return fmt.Errorf("set pasteboard: %w", err)
}
if err := run.sendHID(ctx, pasteChordEvents()...); err != nil {
return fmt.Errorf("send paste chord: %w", err)
}
verifiable := field.identifier != ""
maxPolls := int(pasteVerifyTimeout / pastePoll)
for poll := 0; poll < maxPolls; poll++ {
dump, err := run.describeAll(ctx)
if err != nil {
return fmt.Errorf("describe accessibility: %w", err)
}
if pasteLanded(dump, field.identifier, text) {
return nil
}
if button, found := findAllowPasteButton(dump); found {
// The dialog swallowed the paste; dismiss it, refocus, and resend
// the chord exactly once. Dismissing blacks out the bridge, so the
// landed value only appears on a later poll.
if err := run.sendHID(ctx, tapEvents(button.centerX, button.centerY)...); err != nil {
return fmt.Errorf("tap allow button: %w", err)
}
if err := run.sleep(ctx, dialogSettle); err != nil {
return err
}
if err := run.sendHID(ctx, tapEvents(field.centerX, field.centerY)...); err != nil {
return fmt.Errorf("refocus field: %w", err)
}
if err := run.sleep(ctx, dialogSettle); err != nil {
return err
}
if err := run.sendHID(ctx, pasteChordEvents()...); err != nil {
return fmt.Errorf("send paste chord: %w", err)
}
continue
}
if !verifiable {
// Without a field identifier the paste cannot be confirmed. The
// chord went out and no dialog is blocking it, so one settle is the
// best available behavior.
return run.sleep(ctx, pastePoll)
}
// Field not yet showing the text: either the bridge is still blacked
// out from the dialog or the paste has not rendered. Keep polling until
// the value lands or the budget runs out.
if err := run.sleep(ctx, pastePoll); err != nil {
return err
}
}
return fmt.Errorf("paste did not land within %s", pasteVerifyTimeout)
}
// eraseBackspaceThreshold is the largest erase still sent as individual
// backspaces. Backspaces render progressively on the simulator (tens of
// milliseconds per character), so clearing a long field key-by-key leaves the
// screen churning long after the HID call returns and races whatever input
// follows. Above the threshold the field is cleared atomically instead.
const eraseBackspaceThreshold = 3
// eraseText deletes characterCount characters from the focused field. Small
// counts go as backspaces in one HID stream; larger counts clear the whole
// field via select-all plus one backspace. The runner asks for the field's
// full length when it pre-erases (replace semantics), so treating a large
// count as clear-the-field matches its intent while landing in one frame.
func eraseText(ctx context.Context, run runner, characterCount int) error {
if characterCount <= 0 {
return nil
}
if characterCount <= eraseBackspaceThreshold {
return run.sendHID(ctx, keyPressEvents(backspaces(characterCount))...)
}
return run.sendHID(ctx, clearFieldEvents()...)
}
// keyPressEvents flattens key presses into a HID event stream. A shifted press
// is wrapped with left-shift down before and up after, so the shift modifier is
// held only for that key.
func keyPressEvents(presses []KeyPress) []transport.HIDEvent {
events := make([]transport.HIDEvent, 0, len(presses)*2)
for _, press := range presses {
if press.Shift {
events = append(events, transport.KeyDown(usageLeftShift))
}
events = append(events, transport.KeyDown(press.Usage), transport.KeyUp(press.Usage))
if press.Shift {
events = append(events, transport.KeyUp(usageLeftShift))
}
}
return events
}
// pasteChordEvents is the command+V chord: command down, V down, V up,
// command up.
func pasteChordEvents() []transport.HIDEvent {
return []transport.HIDEvent{
transport.KeyDown(LeftGUI),
transport.KeyDown(VKey),
transport.KeyUp(VKey),
transport.KeyUp(LeftGUI),
}
}
// selectAllChordEvents is the command+A chord selecting the focused field's
// whole content.
func selectAllChordEvents() []transport.HIDEvent {
return []transport.HIDEvent{
transport.KeyDown(LeftGUI),
transport.KeyDown(usageA),
transport.KeyUp(usageA),
transport.KeyUp(LeftGUI),
}
}
// tapEvents is a single tap: finger down then up at one point.
func tapEvents(x, y float64) []transport.HIDEvent {
return []transport.HIDEvent{transport.TouchDown(x, y), transport.TouchUp(x, y)}
}
// doubleTapEvents is two taps in one stream separated by gapMilliseconds.
func doubleTapEvents(x, y float64, gapMilliseconds float64) []transport.HIDEvent {
return []transport.HIDEvent{
transport.TouchDown(x, y), transport.TouchUp(x, y),
transport.Delay(gapMilliseconds),
transport.TouchDown(x, y), transport.TouchUp(x, y),
}
}
// longPressEvents is a finger held down for holdMilliseconds before lifting.
func longPressEvents(x, y float64, holdMilliseconds float64) []transport.HIDEvent {
return []transport.HIDEvent{
transport.TouchDown(x, y),
transport.Delay(holdMilliseconds),
transport.TouchUp(x, y),
}
}
// allowPasteButton is the located allow button of the paste-permission dialog.
type allowPasteButton struct {
centerX float64
centerY float64
}
// allowPasteLabels are the known en-US labels of the paste dialog's accept
// button. iOS also surfaces a reject button ("Don't Allow Paste"), so a plain
// "Allow" substring match would be ambiguous; the located labels are matched
// exactly against the trimmed AXLabel.
var allowPasteLabels = []string{"Allow Paste", "Allow"}
// findAllowPasteButton locates the allow button of the paste-permission dialog
// in a describe-all dump. It first matches a button whose label is a known
// allow label. Failing that, it applies a conservative fallback: if exactly one
// enabled button is present in the dump, that lone button is taken to be the
// dialog's allow control. The fallback is deliberately narrow so it cannot fire
// on an ordinary screen full of buttons; the dialog is modal and collapses the
// dump to its own controls.
func findAllowPasteButton(dump []byte) (allowPasteButton, bool) {
elements := decodeDump(dump)
for _, element := range elements {
if element.Type != "Button" {
continue
}
label := strings.TrimSpace(stringValue(element.AXLabel))
if isRejectPasteLabel(label) {
continue
}
for _, allow := range allowPasteLabels {
if label == allow {
if center, ok := buttonCenter(element); ok {
return center, true
}
}
}
}
var soleEnabled allowPasteButton
enabledButtons := 0
for _, element := range elements {
if element.Type != "Button" || !element.Enabled {
continue
}
if isRejectPasteLabel(strings.TrimSpace(stringValue(element.AXLabel))) {
continue
}
center, ok := buttonCenter(element)
if !ok {
continue
}
enabledButtons++
soleEnabled = center
}
if enabledButtons == 1 {
return soleEnabled, true
}
return allowPasteButton{}, false
}
// isRejectPasteLabel reports whether label is the dialog's reject button. iOS
// renders the apostrophe as a right single quotation mark (U+2019), so both the
// curly and straight forms are checked.
func isRejectPasteLabel(label string) bool {
return strings.Contains(label, "Don’t Allow Paste") ||
strings.Contains(label, "Don't Allow Paste")
}
func buttonCenter(element rawElement) (allowPasteButton, bool) {
frame := element.Frame
if !finite(frame.X) || !finite(frame.Y) || !finite(frame.Width) || !finite(frame.Height) {
return allowPasteButton{}, false
}
if frame.Width == 0 && frame.Height == 0 {
return allowPasteButton{}, false
}
return allowPasteButton{
centerX: frame.X + frame.Width/2,
centerY: frame.Y + frame.Height/2,
}, true
}
// pasteLanded reports whether the field identified by fieldIdentifier now shows
// expectedText in its AXValue. The paste appends at the cursor, so a substring
// match (rather than equality) is used: the field may already hold text. A
// secure field masks its value as bullets, making content verification
// impossible; a non-empty all-bullet value counts as landed.
func pasteLanded(dump []byte, fieldIdentifier, expectedText string) bool {
if fieldIdentifier == "" || expectedText == "" {
return false
}
for _, element := range decodeDump(dump) {
if stringValue(element.AXUniqueID) != fieldIdentifier {
continue
}
value := stringValue(element.AXValue)
if strings.Contains(value, expectedText) {
return true
}
return isMaskedValue(value)
}
return false
}
// isMaskedValue reports whether value is a secure field's masked content:
// non-empty and made up entirely of bullet characters.
func isMaskedValue(value string) bool {
if value == "" {
return false
}
for _, r := range value {
if r != '•' {
return false
}
}
return true
}
// decodeDump parses a flat describe-all dump into elements, reusing the same
// element shape and per-element tolerance as the hierarchy mapper: a single
// malformed entry is skipped rather than discarding the whole dump.
func decodeDump(dump []byte) []rawElement {
var rawElements []json.RawMessage
if len(dump) > 0 {
_ = json.Unmarshal(dump, &rawElements)
}
elements := make([]rawElement, 0, len(rawElements))
for _, raw := range rawElements {
var element rawElement
if err := json.Unmarshal(raw, &element); err != nil {
continue
}
elements = append(elements, element)
}
return elements
}
+433
View File
@@ -0,0 +1,433 @@
package ioscompanion
import (
"context"
"os"
"path/filepath"
"reflect"
"testing"
"time"
"github.com/priyanshujain/sanderling/internal/driver/ioscompanion/transport"
)
func eventsEqual(t *testing.T, got, want []transport.HIDEvent) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("event count: got %d, want %d", len(got), len(want))
}
for i := range want {
if !reflect.DeepEqual(got[i], want[i]) {
t.Fatalf("event %d differs", i)
}
}
}
func TestKeyPressEvents(t *testing.T) {
tests := []struct {
name string
presses []KeyPress
want []transport.HIDEvent
}{
{
name: "lowercase letter is down then up, no shift",
presses: []KeyPress{{Usage: usageA}},
want: []transport.HIDEvent{transport.KeyDown(usageA), transport.KeyUp(usageA)},
},
{
name: "shifted letter wraps with left shift down and up",
presses: []KeyPress{{Usage: usageA, Shift: true}},
want: []transport.HIDEvent{
transport.KeyDown(usageLeftShift),
transport.KeyDown(usageA),
transport.KeyUp(usageA),
transport.KeyUp(usageLeftShift),
},
},
{
name: "empty input yields no events",
presses: nil,
want: []transport.HIDEvent{},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
eventsEqual(t, keyPressEvents(test.presses), test.want)
})
}
}
func TestKeyPressEventsMixedString(t *testing.T) {
presses, skipped := typeString("aB")
if len(skipped) != 0 {
t.Fatalf("unexpected skipped runes: %v", skipped)
}
got := keyPressEvents(presses)
want := []transport.HIDEvent{
transport.KeyDown(usageA), transport.KeyUp(usageA),
transport.KeyDown(usageLeftShift),
transport.KeyDown(usageA + 1),
transport.KeyUp(usageA + 1),
transport.KeyUp(usageLeftShift),
}
eventsEqual(t, got, want)
}
func TestPasteChordEvents(t *testing.T) {
want := []transport.HIDEvent{
transport.KeyDown(LeftGUI),
transport.KeyDown(VKey),
transport.KeyUp(VKey),
transport.KeyUp(LeftGUI),
}
eventsEqual(t, pasteChordEvents(), want)
}
func TestTapEvents(t *testing.T) {
want := []transport.HIDEvent{transport.TouchDown(12, 34), transport.TouchUp(12, 34)}
eventsEqual(t, tapEvents(12, 34), want)
}
func TestDoubleTapEvents(t *testing.T) {
want := []transport.HIDEvent{
transport.TouchDown(5, 6), transport.TouchUp(5, 6),
transport.Delay(70),
transport.TouchDown(5, 6), transport.TouchUp(5, 6),
}
eventsEqual(t, doubleTapEvents(5, 6, DefaultDoubleTapGapMilliseconds), want)
}
func TestLongPressEvents(t *testing.T) {
want := []transport.HIDEvent{
transport.TouchDown(8, 9),
transport.Delay(500),
transport.TouchUp(8, 9),
}
eventsEqual(t, longPressEvents(8, 9, 500), want)
}
func loadDialogDump(t *testing.T) []byte {
t.Helper()
dump, err := os.ReadFile(filepath.Join("testdata", "paste-dialog.json"))
if err != nil {
t.Fatalf("read testdata: %v", err)
}
return dump
}
func TestFindAllowPasteButton(t *testing.T) {
tests := []struct {
name string
dump string
wantFound bool
wantX float64
wantY float64
}{
{
name: "exact Allow Paste wins over Don't Allow Paste",
dump: string(loadDialogDump(t)),
wantFound: true,
wantX: 280, // 210 + 140/2
wantY: 465, // 440 + 50/2
},
{
name: "plain Allow label matches",
dump: `[{"type":"Button","AXLabel":"Allow","frame":{"x":100,"y":100,"width":40,"height":20},"enabled":true}]`,
wantFound: true,
wantX: 120,
wantY: 110,
},
{
name: "sole enabled button fallback",
dump: `[{"type":"StaticText","AXLabel":"Paste?","frame":{"x":0,"y":0,"width":10,"height":10},"enabled":true},{"type":"Button","AXLabel":"OK","frame":{"x":50,"y":60,"width":100,"height":40},"enabled":true}]`,
wantFound: true,
wantX: 100,
wantY: 80,
},
{
name: "no fallback when several enabled buttons present",
dump: `[{"type":"Button","AXLabel":"A","frame":{"x":0,"y":0,"width":10,"height":10},"enabled":true},{"type":"Button","AXLabel":"B","frame":{"x":20,"y":0,"width":10,"height":10},"enabled":true}]`,
wantFound: false,
},
{
name: "reject button alone does not match",
dump: `[{"type":"Button","AXLabel":"Don’t Allow Paste","frame":{"x":0,"y":0,"width":10,"height":10},"enabled":true}]`,
wantFound: false,
},
{
name: "empty dump finds nothing",
dump: ``,
wantFound: false,
},
{
name: "malformed json finds nothing",
dump: `not json`,
wantFound: false,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
button, found := findAllowPasteButton([]byte(test.dump))
if found != test.wantFound {
t.Fatalf("found: got %v, want %v", found, test.wantFound)
}
if !found {
return
}
if button.centerX != test.wantX || button.centerY != test.wantY {
t.Fatalf("center: got (%v,%v), want (%v,%v)", button.centerX, button.centerY, test.wantX, test.wantY)
}
})
}
}
func TestPasteLanded(t *testing.T) {
dump := `[{"type":"TextField","AXUniqueId":"NoteField","AXValue":"prefix Café ☕","frame":{"x":0,"y":0,"width":10,"height":10}}]`
tests := []struct {
name string
dump string
identifier string
expected string
want bool
}{
{name: "substring present", dump: dump, identifier: "NoteField", expected: "Café ☕", want: true},
{name: "value not yet landed", dump: `[{"type":"TextField","AXUniqueId":"NoteField","AXValue":"prefix"}]`, identifier: "NoteField", expected: "Café", want: false},
{name: "field absent", dump: dump, identifier: "MissingField", expected: "Café", want: false},
{name: "empty identifier never matches", dump: dump, identifier: "", expected: "Café", want: false},
{name: "empty expected never matches", dump: dump, identifier: "NoteField", expected: "", want: false},
{name: "malformed dump never matches", dump: `nope`, identifier: "NoteField", expected: "Café", want: false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := pasteLanded([]byte(test.dump), test.identifier, test.expected); got != test.want {
t.Fatalf("got %v, want %v", got, test.want)
}
})
}
}
// fakeRunner records side effects and serves scripted describe-all dumps.
type fakeRunner struct {
pasteboard string
hidStreams [][]transport.HIDEvent
dumps [][]byte
dumpIndex int
setError error
sendError error
describeErr error
sleepCount int
}
func (f *fakeRunner) setPasteboard(ctx context.Context, text string) error {
if f.setError != nil {
return f.setError
}
f.pasteboard = text
return nil
}
func (f *fakeRunner) sendHID(ctx context.Context, events ...transport.HIDEvent) error {
if f.sendError != nil {
return f.sendError
}
f.hidStreams = append(f.hidStreams, events)
return nil
}
func (f *fakeRunner) describeAll(ctx context.Context) ([]byte, error) {
if f.describeErr != nil {
return nil, f.describeErr
}
if f.dumpIndex >= len(f.dumps) {
return f.dumps[len(f.dumps)-1], nil
}
dump := f.dumps[f.dumpIndex]
f.dumpIndex++
return dump, nil
}
func (f *fakeRunner) sleep(ctx context.Context, duration time.Duration) error {
f.sleepCount++
return ctx.Err()
}
func TestInputTextFastPathUsesHardwareKeyboard(t *testing.T) {
fake := &fakeRunner{}
if err := inputText(context.Background(), fake, "hi", fieldTarget{}); err != nil {
t.Fatalf("inputText: %v", err)
}
if fake.pasteboard != "" {
t.Fatalf("fast path must not touch pasteboard, got %q", fake.pasteboard)
}
if len(fake.hidStreams) != 1 {
t.Fatalf("fast path must send one HID stream, got %d", len(fake.hidStreams))
}
// One stream: clear the field, then type the runes.
want := append(clearFieldEvents(),
keyPressEvents([]KeyPress{{Usage: usageA + ('h' - 'a')}, {Usage: usageA + ('i' - 'a')}})...)
eventsEqual(t, fake.hidStreams[0], want)
}
func TestInputTextPasteLandsImmediately(t *testing.T) {
landed := `[{"type":"TextField","AXUniqueId":"F","AXValue":"Café"}]`
fake := &fakeRunner{dumps: [][]byte{[]byte(landed)}}
field := fieldTarget{identifier: "F", centerX: 100, centerY: 200}
if err := inputText(context.Background(), fake, "Café", field); err != nil {
t.Fatalf("inputText: %v", err)
}
if fake.pasteboard != "Café" {
t.Fatalf("pasteboard: got %q", fake.pasteboard)
}
// Streams: clear field, then paste chord.
if len(fake.hidStreams) != 2 {
t.Fatalf("expected clear then paste chord, got %d streams", len(fake.hidStreams))
}
eventsEqual(t, fake.hidStreams[0], clearFieldEvents())
eventsEqual(t, fake.hidStreams[1], pasteChordEvents())
}
func TestInputTextPasteDismissesDialogThenLands(t *testing.T) {
dialog := string(loadDialogDump(t))
landed := `[{"type":"TextField","AXUniqueId":"TxnNoteField","AXValue":"Café ☕ 😀"}]`
// First check sees the dialog (which swallowed the initial chord); the
// check after the retried chord sees the landed value.
fake := &fakeRunner{dumps: [][]byte{[]byte(dialog), []byte(landed)}}
field := fieldTarget{identifier: "TxnNoteField", centerX: 195, centerY: 222}
if err := inputText(context.Background(), fake, "Café ☕ 😀", field); err != nil {
t.Fatalf("inputText: %v", err)
}
// Streams: clear field, paste chord, tap allow, refocus field, paste chord again.
if len(fake.hidStreams) != 5 {
t.Fatalf("expected 5 HID streams, got %d", len(fake.hidStreams))
}
eventsEqual(t, fake.hidStreams[0], clearFieldEvents())
eventsEqual(t, fake.hidStreams[1], pasteChordEvents())
eventsEqual(t, fake.hidStreams[2], tapEvents(280, 465))
eventsEqual(t, fake.hidStreams[3], tapEvents(195, 222))
eventsEqual(t, fake.hidStreams[4], pasteChordEvents())
}
func TestInputTextPasteFailsAfterAllAttempts(t *testing.T) {
stuck := `[{"type":"TextField","AXUniqueId":"F","AXValue":""}]`
fake := &fakeRunner{dumps: [][]byte{[]byte(stuck)}}
field := fieldTarget{identifier: "F", centerX: 1, centerY: 2}
err := inputText(context.Background(), fake, "😀", field)
if err == nil {
t.Fatal("expected error after exhausting the verify budget")
}
// Streams: clear field, then exactly one paste chord (no dialog, so it is
// not re-sent: re-sending on a slow render would paste twice).
if len(fake.hidStreams) != 2 {
t.Fatalf("expected clear then one paste chord, got %d streams", len(fake.hidStreams))
}
eventsEqual(t, fake.hidStreams[0], clearFieldEvents())
wantPolls := int(pasteVerifyTimeout / pastePoll)
if fake.sleepCount != wantPolls {
t.Fatalf("expected %d verify polls, got %d", wantPolls, fake.sleepCount)
}
}
func TestInputTextPasteLandsAfterBridgeBlackout(t *testing.T) {
// The field is absent (bridge blacked out by the dialog) for several
// polls, then the value lands. The loop must keep waiting through the
// blackout instead of failing.
blacked := `[{"type":"Application"}]`
landed := `[{"type":"TextField","AXUniqueId":"F","AXValue":"😀"}]`
dumps := [][]byte{[]byte(blacked), []byte(blacked), []byte(blacked), []byte(landed)}
fake := &fakeRunner{dumps: dumps}
field := fieldTarget{identifier: "F", centerX: 1, centerY: 2}
if err := inputText(context.Background(), fake, "😀", field); err != nil {
t.Fatalf("inputText: %v", err)
}
// Clear field, one chord, no dialog seen, success once the value appears.
if len(fake.hidStreams) != 2 {
t.Fatalf("expected clear then one paste chord, got %d streams", len(fake.hidStreams))
}
}
func TestInputTextPasteUnverifiableFieldSingleChord(t *testing.T) {
// No field identifier resolved: one chord, no dialog, success after the
// settle without endless retries.
noField := `[{"type":"StaticText","AXLabel":"whatever"}]`
fake := &fakeRunner{dumps: [][]byte{[]byte(noField)}}
if err := inputText(context.Background(), fake, "😀", fieldTarget{}); err != nil {
t.Fatalf("inputText: %v", err)
}
// Clear field, then one paste chord.
if len(fake.hidStreams) != 2 {
t.Fatalf("expected clear then one paste chord, got %d streams", len(fake.hidStreams))
}
}
func TestEraseTextLargeCountClearsAtomically(t *testing.T) {
fake := &fakeRunner{}
if err := eraseText(context.Background(), fake, 40); err != nil {
t.Fatalf("eraseText: %v", err)
}
if len(fake.hidStreams) != 1 {
t.Fatalf("expected one stream, got %d", len(fake.hidStreams))
}
eventsEqual(t, fake.hidStreams[0], clearFieldEvents())
}
func TestEraseTextSendsBackspaces(t *testing.T) {
fake := &fakeRunner{}
if err := eraseText(context.Background(), fake, 3); err != nil {
t.Fatalf("eraseText: %v", err)
}
if len(fake.hidStreams) != 1 {
t.Fatalf("expected one stream, got %d", len(fake.hidStreams))
}
want := keyPressEvents(backspaces(3))
eventsEqual(t, fake.hidStreams[0], want)
}
func TestEraseTextZeroIsNoOp(t *testing.T) {
fake := &fakeRunner{}
if err := eraseText(context.Background(), fake, 0); err != nil {
t.Fatalf("eraseText: %v", err)
}
if len(fake.hidStreams) != 0 {
t.Fatalf("zero count must send nothing, got %d streams", len(fake.hidStreams))
}
}
func TestUsesPasteboard(t *testing.T) {
cases := []struct {
text string
want bool
}{
{"", false},
{"a", false},
{"abc", false},
{"a long but fully mappable ascii string", false},
{"-1", false},
{"%s%n", false},
{"😀", true},
{"Café", true},
}
for _, c := range cases {
if got := usesPasteboard(c.text); got != c.want {
t.Errorf("usesPasteboard(%q) = %v, want %v", c.text, got, c.want)
}
}
}
func TestPasteLandedMaskedSecureField(t *testing.T) {
cases := []struct {
name string
dump string
want bool
}{
{name: "all bullets counts as landed", dump: `[{"type":"TextField","AXUniqueId":"PW","AXValue":"•••••"}]`, want: true},
{name: "empty secure field not landed", dump: `[{"type":"TextField","AXUniqueId":"PW","AXValue":""}]`, want: false},
{name: "mixed bullets and text not masked", dump: `[{"type":"TextField","AXUniqueId":"PW","AXValue":"••a"}]`, want: false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := pasteLanded([]byte(c.dump), "PW", "secret9"); got != c.want {
t.Fatalf("got %v, want %v", got, c.want)
}
})
}
}
+129
View File
@@ -0,0 +1,129 @@
// Package ioscompanion talks to the simulator companion to drive an iOS
// simulator. This file is pure data: it maps runes to USB HID keyboard
// usage IDs. A later module turns these key presses into HID events.
package ioscompanion
// USB HID keyboard usage IDs.
const (
usageA = 4
usage1 = 30
usage0 = 39
usageReturn = 40
usageTab = 43
usageSpace = 44
usageBackspace = 42
usageMinus = 45
usageEqual = 46
usageLeftBracket = 47
usageRightBracket = 48
usageBackslash = 49
usageSemicolon = 51
usageApostrophe = 52
usageGrave = 53
usageComma = 54
usagePeriod = 55
usageSlash = 56
usageLeftShift = 225
// LeftGUI is the command key. VKey is the letter V. Together they form
// the paste chord (command+V).
LeftGUI = 227
VKey = 25
)
// KeyPress is a single key with whether the shift modifier is held.
type KeyPress struct {
Usage uint32
Shift bool
}
// shiftedSymbols maps a shifted-symbol rune to the unshifted key it lives on.
var shiftedSymbols = map[rune]uint32{
'!': usage1,
'@': usage1 + 1,
'#': usage1 + 2,
'$': usage1 + 3,
'%': usage1 + 4,
'^': usage1 + 5,
'&': usage1 + 6,
'*': usage1 + 7,
'(': usage1 + 8,
')': usage0,
'_': usageMinus,
'+': usageEqual,
'{': usageLeftBracket,
'}': usageRightBracket,
'|': usageBackslash,
':': usageSemicolon,
'"': usageApostrophe,
'~': usageGrave,
'<': usageComma,
'>': usagePeriod,
'?': usageSlash,
}
// unshiftedSymbols maps a symbol rune typed without shift to its key.
var unshiftedSymbols = map[rune]uint32{
'-': usageMinus,
'=': usageEqual,
'[': usageLeftBracket,
']': usageRightBracket,
'\\': usageBackslash,
';': usageSemicolon,
'\'': usageApostrophe,
'`': usageGrave,
',': usageComma,
'.': usagePeriod,
'/': usageSlash,
}
// charKey returns the key press for a rune and whether the rune is mappable.
func charKey(r rune) (KeyPress, bool) {
switch {
case r >= 'a' && r <= 'z':
return KeyPress{Usage: uint32(usageA + (r - 'a'))}, true
case r >= 'A' && r <= 'Z':
return KeyPress{Usage: uint32(usageA + (r - 'A')), Shift: true}, true
case r >= '1' && r <= '9':
return KeyPress{Usage: uint32(usage1 + (r - '1'))}, true
case r == '0':
return KeyPress{Usage: usage0}, true
case r == ' ':
return KeyPress{Usage: usageSpace}, true
case r == '\n':
return KeyPress{Usage: usageReturn}, true
case r == '\t':
return KeyPress{Usage: usageTab}, true
}
if usage, ok := unshiftedSymbols[r]; ok {
return KeyPress{Usage: usage}, true
}
if usage, ok := shiftedSymbols[r]; ok {
return KeyPress{Usage: usage, Shift: true}, true
}
return KeyPress{}, false
}
// typeString returns the ordered key presses for every mappable rune in s.
// Unmappable runes are collected in order in skipped.
func typeString(s string) (presses []KeyPress, skipped []rune) {
for _, r := range s {
press, ok := charKey(r)
if !ok {
skipped = append(skipped, r)
continue
}
presses = append(presses, press)
}
return presses, skipped
}
// backspaces returns n backspace key presses.
func backspaces(n int) []KeyPress {
presses := make([]KeyPress, 0, n)
for i := 0; i < n; i++ {
presses = append(presses, KeyPress{Usage: usageBackspace})
}
return presses
}
+209
View File
@@ -0,0 +1,209 @@
package ioscompanion
import (
"reflect"
"testing"
)
func TestCharKeyLetters(t *testing.T) {
cases := []struct {
r rune
usage uint32
shift bool
}{
{'a', usageA, false},
{'z', usageA + 25, false},
{'m', usageA + 12, false},
{'A', usageA, true},
{'Z', usageA + 25, true},
{'M', usageA + 12, true},
}
for _, c := range cases {
press, ok := charKey(c.r)
if !ok {
t.Fatalf("charKey(%q) reported unmappable", c.r)
}
if press.Usage != c.usage || press.Shift != c.shift {
t.Errorf("charKey(%q) = {%d,%v}, want {%d,%v}", c.r, press.Usage, press.Shift, c.usage, c.shift)
}
}
}
func TestCharKeyDigits(t *testing.T) {
cases := []struct {
r rune
usage uint32
}{
{'1', usage1},
{'2', usage1 + 1},
{'3', usage1 + 2},
{'4', usage1 + 3},
{'5', usage1 + 4},
{'6', usage1 + 5},
{'7', usage1 + 6},
{'8', usage1 + 7},
{'9', usage1 + 8},
{'0', usage0},
}
for _, c := range cases {
press, ok := charKey(c.r)
if !ok {
t.Fatalf("charKey(%q) reported unmappable", c.r)
}
if press.Usage != c.usage || press.Shift {
t.Errorf("charKey(%q) = {%d,%v}, want {%d,false}", c.r, press.Usage, press.Shift, c.usage)
}
}
}
func TestCharKeyWhitespace(t *testing.T) {
cases := []struct {
r rune
usage uint32
}{
{' ', usageSpace},
{'\n', usageReturn},
{'\t', usageTab},
}
for _, c := range cases {
press, ok := charKey(c.r)
if !ok {
t.Fatalf("charKey(%q) reported unmappable", c.r)
}
if press.Usage != c.usage || press.Shift {
t.Errorf("charKey(%q) = {%d,%v}, want {%d,false}", c.r, press.Usage, press.Shift, c.usage)
}
}
}
func TestCharKeyUnshiftedSymbols(t *testing.T) {
cases := []struct {
r rune
usage uint32
}{
{'-', usageMinus},
{'=', usageEqual},
{'[', usageLeftBracket},
{']', usageRightBracket},
{'\\', usageBackslash},
{';', usageSemicolon},
{'\'', usageApostrophe},
{'`', usageGrave},
{',', usageComma},
{'.', usagePeriod},
{'/', usageSlash},
}
for _, c := range cases {
press, ok := charKey(c.r)
if !ok {
t.Fatalf("charKey(%q) reported unmappable", c.r)
}
if press.Usage != c.usage || press.Shift {
t.Errorf("charKey(%q) = {%d,%v}, want {%d,false}", c.r, press.Usage, press.Shift, c.usage)
}
}
}
func TestCharKeyShiftedSymbols(t *testing.T) {
cases := []struct {
r rune
usage uint32
}{
{'!', usage1},
{'@', usage1 + 1},
{'#', usage1 + 2},
{'$', usage1 + 3},
{'%', usage1 + 4},
{'^', usage1 + 5},
{'&', usage1 + 6},
{'*', usage1 + 7},
{'(', usage1 + 8},
{')', usage0},
{'_', usageMinus},
{'+', usageEqual},
{'{', usageLeftBracket},
{'}', usageRightBracket},
{'|', usageBackslash},
{':', usageSemicolon},
{'"', usageApostrophe},
{'~', usageGrave},
{'<', usageComma},
{'>', usagePeriod},
{'?', usageSlash},
}
for _, c := range cases {
press, ok := charKey(c.r)
if !ok {
t.Fatalf("charKey(%q) reported unmappable", c.r)
}
if press.Usage != c.usage || !press.Shift {
t.Errorf("charKey(%q) = {%d,%v}, want {%d,true}", c.r, press.Usage, press.Shift, c.usage)
}
}
}
func TestCharKeyUnmappable(t *testing.T) {
for _, r := range []rune{'é', '世', '🙂', '\x00'} {
if press, ok := charKey(r); ok {
t.Errorf("charKey(%q) = {%d,%v}, want unmappable", r, press.Usage, press.Shift)
}
}
}
func TestTypeString(t *testing.T) {
presses, skipped := typeString("Ab1!")
want := []KeyPress{
{Usage: usageA, Shift: true},
{Usage: usageA + 1},
{Usage: usage1},
{Usage: usage1, Shift: true},
}
if !reflect.DeepEqual(presses, want) {
t.Errorf("presses = %+v, want %+v", presses, want)
}
if len(skipped) != 0 {
t.Errorf("skipped = %v, want none", skipped)
}
}
func TestTypeStringSkipsUnmappableInOrder(t *testing.T) {
presses, skipped := typeString("café 🙂x")
wantPresses := []KeyPress{
{Usage: usageA + 2},
{Usage: usageA},
{Usage: usageA + 5},
{Usage: usageSpace},
{Usage: usageA + 23},
}
if !reflect.DeepEqual(presses, wantPresses) {
t.Errorf("presses = %+v, want %+v", presses, wantPresses)
}
wantSkipped := []rune{'é', '🙂'}
if !reflect.DeepEqual(skipped, wantSkipped) {
t.Errorf("skipped = %q, want %q", skipped, wantSkipped)
}
}
func TestBackspaces(t *testing.T) {
if got := backspaces(0); len(got) != 0 {
t.Errorf("backspaces(0) = %v, want empty", got)
}
got := backspaces(3)
want := []KeyPress{
{Usage: usageBackspace},
{Usage: usageBackspace},
{Usage: usageBackspace},
}
if !reflect.DeepEqual(got, want) {
t.Errorf("backspaces(3) = %+v, want %+v", got, want)
}
}
func TestPasteChordConstants(t *testing.T) {
if LeftGUI != 227 {
t.Errorf("LeftGUI = %d, want 227", LeftGUI)
}
if VKey != 25 {
t.Errorf("VKey = %d, want 25", VKey)
}
}
@@ -0,0 +1,140 @@
// Package runnerassets embeds the in-simulator runner test bundle and its
// xctestrun, and extracts them to disk at runtime.
package runnerassets
import (
"archive/tar"
"bytes"
"compress/gzip"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
// xctestrunName is the file name the staged test root uses for the runner's
// xctestrun. The driver substitutes the port placeholder in this file before
// launching, so it must not be renamed.
const xctestrunName = "runner.xctestrun"
// EmbeddedSize returns the size in bytes of the embedded runner archive.
func EmbeddedSize() int { return len(embeddedArchive) }
// EmbeddedSHA256 returns the hex-encoded SHA-256 of the embedded archive.
func EmbeddedSHA256() string {
sum := sha256.Sum256(embeddedArchive)
return hex.EncodeToString(sum[:])
}
// Extract unpacks the embedded runner archive into dir, preserving the test
// root layout and any symlinks. A .sha256 marker next to the extracted tree
// gates re-extraction: if it already matches, no rewrite happens. Returns the
// absolute path to the extracted runner.xctestrun.
func Extract(dir string) (string, error) {
if len(embeddedArchive) == 0 {
return "", errors.New("runner: binary built without -tags withcompanion; rebuild with `make sanderling`")
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", fmt.Errorf("mkdir %s: %w", dir, err)
}
xctestrunPath, err := filepath.Abs(filepath.Join(dir, xctestrunName))
if err != nil {
return "", err
}
checksumPath := filepath.Join(dir, "runner.sha256")
checksum := EmbeddedSHA256()
if existing, err := os.ReadFile(checksumPath); err == nil && string(existing) == checksum {
if _, err := os.Stat(xctestrunPath); err == nil {
return xctestrunPath, nil
}
}
if err := unpack(dir); err != nil {
return "", err
}
if _, err := os.Stat(xctestrunPath); err != nil {
return "", fmt.Errorf("runner: xctestrun missing after extraction: %w", err)
}
if err := os.WriteFile(checksumPath, []byte(checksum), 0o644); err != nil {
return "", fmt.Errorf("write checksum: %w", err)
}
return xctestrunPath, nil
}
func unpack(dir string) error {
gzipReader, err := gzip.NewReader(bytes.NewReader(embeddedArchive))
if err != nil {
return fmt.Errorf("open runner archive: %w", err)
}
defer gzipReader.Close()
tarReader := tar.NewReader(gzipReader)
for {
header, err := tarReader.Next()
if err == io.EOF {
return nil
}
if err != nil {
return fmt.Errorf("read runner archive: %w", err)
}
if strings.HasPrefix(filepath.Base(header.Name), "._") {
continue
}
target, err := safeJoin(dir, header.Name)
if err != nil {
return err
}
switch header.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(target, 0o755); err != nil {
return err
}
case tar.TypeSymlink:
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
os.Remove(target)
if err := os.Symlink(header.Linkname, target); err != nil {
return err
}
case tar.TypeReg:
if err := writeFile(target, tarReader, os.FileMode(header.Mode)); err != nil {
return err
}
}
}
}
func writeFile(path string, src io.Reader, mode os.FileMode) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
file, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode)
if err != nil {
return err
}
if _, err := io.Copy(file, src); err != nil {
file.Close()
return err
}
return file.Close()
}
// safeJoin rejects archive entries that would escape dir.
func safeJoin(dir, name string) (string, error) {
target := filepath.Join(dir, name)
relative, err := filepath.Rel(dir, target)
if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(os.PathSeparator)) {
return "", fmt.Errorf("archive entry escapes destination: %s", name)
}
return target, nil
}
@@ -0,0 +1,12 @@
//go:build withcompanion
package runnerassets
import _ "embed"
//go:embed assets/runner-1.0.0.tar.gz
var embeddedArchive []byte
// IsPlaceholder reports whether the binary was built without the real runner
// archive embedded. -tags withcompanion builds always return false.
func IsPlaceholder() bool { return false }
@@ -0,0 +1,10 @@
//go:build !withcompanion
package runnerassets
var embeddedArchive []byte
// IsPlaceholder reports whether the binary was built without the real runner
// archive embedded. Build with `make sanderling` (which passes
// -tags withcompanion) to embed the real runner test bundle.
func IsPlaceholder() bool { return true }
@@ -0,0 +1,27 @@
//go:build !withcompanion
package runnerassets
import (
"strings"
"testing"
)
func TestStubBuild_IsPlaceholder(t *testing.T) {
if !IsPlaceholder() {
t.Error("default build (no -tags withcompanion) must report a placeholder")
}
if EmbeddedSize() != 0 {
t.Errorf("placeholder build must embed no archive, got %d bytes", EmbeddedSize())
}
}
func TestStubBuild_ExtractErrors(t *testing.T) {
_, err := Extract(t.TempDir())
if err == nil {
t.Fatal("Extract must fail when no archive is embedded")
}
if !strings.Contains(err.Error(), "withcompanion") {
t.Errorf("error should tell the user to rebuild with -tags withcompanion, got %v", err)
}
}
@@ -0,0 +1,143 @@
//go:build withcompanion
package runnerassets
import (
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"strings"
"testing"
)
func TestEmbeddedNonZero(t *testing.T) {
if EmbeddedSize() == 0 {
t.Errorf("expected embedded runner archive to be non-empty")
}
if IsPlaceholder() {
t.Errorf("withcompanion build should not be a placeholder")
}
}
func TestEmbeddedSHA256Matches(t *testing.T) {
sum := sha256.Sum256(embeddedArchive)
if hex.EncodeToString(sum[:]) != EmbeddedSHA256() {
t.Errorf("EmbeddedSHA256 does not match a fresh hash of the archive")
}
}
func TestExtract_WritesXctestrunAndChecksum(t *testing.T) {
directory := t.TempDir()
path, err := Extract(directory)
if err != nil {
t.Fatal(err)
}
expected, err := filepath.Abs(filepath.Join(directory, xctestrunName))
if err != nil {
t.Fatal(err)
}
if path != expected {
t.Errorf("unexpected xctestrun path: got %s want %s", path, expected)
}
contents, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(contents), "__COMPANION_PORT__") {
t.Errorf("extracted xctestrun is missing the __COMPANION_PORT__ placeholder")
}
checksum, err := os.ReadFile(filepath.Join(directory, "runner.sha256"))
if err != nil {
t.Fatal(err)
}
if string(checksum) != EmbeddedSHA256() {
t.Errorf("checksum file content wrong: %q", checksum)
}
}
func TestExtract_Layout(t *testing.T) {
directory := t.TempDir()
if _, err := Extract(directory); err != nil {
t.Fatal(err)
}
runnerApp := filepath.Join(directory, "Debug-iphonesimulator", "CompanionRunnerUITests-Runner.app")
if stat, err := os.Stat(runnerApp); err != nil || !stat.IsDir() {
t.Fatalf("runner app directory missing: %v", err)
}
binary := filepath.Join(runnerApp, "CompanionRunnerUITests-Runner")
if _, err := os.Stat(binary); err != nil {
t.Errorf("runner app binary missing: %v", err)
}
}
func TestExtract_SymlinksResolve(t *testing.T) {
directory := t.TempDir()
if _, err := Extract(directory); err != nil {
t.Fatal(err)
}
// Simulator bundles are flat, so the runner app may contain no symlinks at
// all. Any symlink that is present must resolve to a real target, proving
// the unpack path restores links rather than copying broken stubs.
root := filepath.Join(directory, "Debug-iphonesimulator")
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 {
if _, statErr := os.Stat(path); statErr != nil {
t.Errorf("symlink %s does not resolve: %v", path, statErr)
}
}
return nil
})
if err != nil {
t.Fatal(err)
}
}
func TestExtract_ReusesWhenChecksumMatches(t *testing.T) {
directory := t.TempDir()
xctestrunPath, err := Extract(directory)
if err != nil {
t.Fatal(err)
}
sentinel := []byte("SENTINEL-do-not-rewrite")
if err := os.WriteFile(xctestrunPath, sentinel, 0o644); err != nil {
t.Fatal(err)
}
if _, err := Extract(directory); err != nil {
t.Fatal(err)
}
after, err := os.ReadFile(xctestrunPath)
if err != nil {
t.Fatal(err)
}
if string(after) != string(sentinel) {
t.Errorf("second extract rewrote the xctestrun; reuse branch should have skipped extraction")
}
}
func TestExtract_RewritesIfChecksumMissing(t *testing.T) {
directory := t.TempDir()
if _, err := Extract(directory); err != nil {
t.Fatal(err)
}
checksumPath := filepath.Join(directory, "runner.sha256")
if err := os.Remove(checksumPath); err != nil {
t.Fatal(err)
}
if _, err := Extract(directory); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(checksumPath); err != nil {
t.Errorf("checksum should have been rewritten: %v", err)
}
}
@@ -0,0 +1,89 @@
package ioscompanion
import (
"bytes"
"context"
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"syscall"
"github.com/priyanshujain/sanderling/internal/driver/ioscompanion/runnerassets"
)
// runnerPortPlaceholder is the marker the prepare script plants in the
// packaged test configuration's environment. The spawner substitutes the
// session's port before launching.
const runnerPortPlaceholder = "__COMPANION_PORT__"
// realSpawnRunner extracts the embedded runner bundle, writes a test
// configuration bound to the session's port, and starts the hosting test
// session against the configured simulator. Cancel sends SIGTERM so the
// session tears down its in-simulator children cleanly.
func (d *Driver) realSpawnRunner(ctx context.Context, address string) (*exec.Cmd, error) {
extractDirectory := filepath.Join(os.TempDir(), "sanderling-runner")
testRunPath, err := runnerassets.Extract(extractDirectory)
if err != nil {
return nil, fmt.Errorf("extract runner: %w", err)
}
_, port, err := net.SplitHostPort(address)
if err != nil {
return nil, err
}
boundTestRunPath, err := bindTestRunPort(testRunPath, port)
if err != nil {
return nil, err
}
logPath := filepath.Join(extractDirectory, "runner-session-"+port+".log")
logFile, err := os.Create(logPath)
if err != nil {
return nil, fmt.Errorf("create runner log: %w", err)
}
command := exec.CommandContext(ctx, "xcrun", "xcodebuild", "test-without-building",
"-xctestrun", boundTestRunPath,
"-destination", "platform=iOS Simulator,id="+d.udid)
// The session log stays out of the run output: the build tool is noisy
// and would pollute the run's error scan. The path is reported once so a
// failed startup is diagnosable.
command.Stdout = logFile
command.Stderr = logFile
command.Env = []string{
"HOME=" + os.Getenv("HOME"),
"PATH=/usr/bin:/bin",
"TMPDIR=" + os.TempDir(),
}
command.Cancel = func() error { return command.Process.Signal(syscall.SIGTERM) }
command.WaitDelay = shutdownGrace
startErr := command.Start()
// The child holds its own descriptor after Start, so the parent's copy
// closes either way.
logFile.Close()
if startErr != nil {
return nil, fmt.Errorf("start runner session: %w", startErr)
}
fmt.Fprintf(d.output, "runner session pid=%d listening on %s (log: %s)\n", command.Process.Pid, address, logPath)
return command, nil
}
// bindTestRunPort writes a copy of the extracted test configuration with the
// port placeholder substituted, next to the original so its relative paths
// keep resolving. Returns the bound copy's path.
func bindTestRunPort(testRunPath, port string) (string, error) {
configuration, err := os.ReadFile(testRunPath)
if err != nil {
return "", fmt.Errorf("read test configuration: %w", err)
}
if !bytes.Contains(configuration, []byte(runnerPortPlaceholder)) {
return "", fmt.Errorf("test configuration %s carries no %s placeholder", testRunPath, runnerPortPlaceholder)
}
bound := bytes.ReplaceAll(configuration, []byte(runnerPortPlaceholder), []byte(port))
boundPath := filepath.Join(filepath.Dir(testRunPath), "runner-"+port+".xctestrun")
if err := os.WriteFile(boundPath, bound, 0o644); err != nil {
return "", fmt.Errorf("write bound test configuration: %w", err)
}
return boundPath, nil
}
+198
View File
@@ -0,0 +1,198 @@
// Package ioscompanion drives an iOS simulator through the native simulator
// companion. This file ports the screen-settle (stability polling) logic that
// waits for the on-device UI to stop churning before the runner reads a
// hierarchy and screenshot pair.
package ioscompanion
import (
"context"
"strings"
"time"
"github.com/priyanshujain/sanderling/internal/hierarchy"
)
// StabilityPollInterval is how long the loop waits between hierarchy probes.
// The companion answers describe-all in tens of milliseconds, so a tight
// interval samples transitions promptly without backing the stream up.
const StabilityPollInterval = 100 * time.Millisecond
// MinStableStreak is how long the tree must stay structurally identical (and
// non-transitional) before the poll declares settle. Actions whose effect is
// async (a tap that fires a write which later pops the back stack) leave the
// UI momentarily stable before the navigation transition fires; requiring an
// uninterrupted streak means churn that starts during the window resets the
// clock instead of being missed. The streak is shorter than the JVM sidecar's
// (which polls a slower, flakier adb hierarchy): the companion's describe is
// fast and deterministic, so three consecutive identical samples are a solid
// stability signal, and cross-fade transitions are caught separately by the
// route-screen transitional check rather than by streak length.
const MinStableStreak = 300 * time.Millisecond
// StabilityPollCap bounds total time spent polling so a UI that never settles
// does not block the runner indefinitely. A genuinely still-churning screen
// hands a transitional snapshot to the next step, which settles it, so the cap
// trades a slightly stale read for bounded latency rather than dropping work.
const StabilityPollCap = 1500 * time.Millisecond
// Clock abstracts time so the poll loop can be driven by a fake in tests
// without sleeping for real.
type Clock interface {
Now() time.Time
Sleep(duration time.Duration)
}
// systemClock is the production Clock backed by the standard library.
type systemClock struct{}
func (systemClock) Now() time.Time { return time.Now() }
func (systemClock) Sleep(d time.Duration) { time.Sleep(d) }
// SystemClock returns a Clock backed by the standard library wall clock.
func SystemClock() Clock { return systemClock{} }
// PollUntilStable returns once StabilitySnapshot has been non-transitional and
// equal to itself for an uninterrupted stretch of at least MinStableStreak,
// capped at StabilityPollCap. fetch returns the current hierarchy tree (nil on
// fetch failure, treated like a transitional snapshot so the streak resets).
//
// The stable stretch is measured from the start of the earliest read in the
// current run of identical snapshots: a fetch is not instantaneous (a runner
// snapshot takes a fair fraction of the streak itself), and the UI changing
// mid-read would change the snapshot, so the read's own duration is evidence
// of stability. A transitional snapshot, a changed snapshot, or a fetch
// failure resets the run. The function returns when the stretch reaches
// MinStableStreak or the cap elapses, and respects context cancellation.
func PollUntilStable(ctx context.Context, clock Clock, fetch func() *hierarchy.Tree) {
deadline := clock.Now().Add(StabilityPollCap)
runStart := clock.Now()
prior := snapshot(fetch)
for clock.Now().Before(deadline) {
if ctx.Err() != nil {
return
}
clock.Sleep(StabilityPollInterval)
currentStart := clock.Now()
current := snapshot(fetch)
if prior.valid && current.valid && prior.hash == current.hash {
if clock.Now().Sub(runStart) >= MinStableStreak {
return
}
} else {
runStart = currentStart
}
prior = current
}
}
// stableSnapshot is the result of probing a single hierarchy tree. valid is
// false when the snapshot is transitional (mid route transition) or the fetch
// failed, both of which must reset the stable streak.
type stableSnapshot struct {
hash string
valid bool
}
func snapshot(fetch func() *hierarchy.Tree) stableSnapshot {
tree := fetch()
if tree == nil {
return stableSnapshot{}
}
return StabilitySnapshot(tree)
}
// StabilitySnapshot probes a hierarchy tree for both structural shape and route
// transition state. An empty tree is a valid stable snapshot (the blank-tree
// case in the companion). A tree with more than one route Screen is
// transitional and reported invalid: a navigation host keeps both source and
// destination destinations alive during a cross-fade, and a snapshot taken in
// that window is unreliable because lazy lists in the incoming screen mount
// over several frames. Otherwise the snapshot carries the structural hash.
func StabilitySnapshot(tree *hierarchy.Tree) stableSnapshot {
if tree == nil || tree.Root == nil {
return stableSnapshot{valid: true}
}
if CountRouteScreens(tree) > 1 {
return stableSnapshot{}
}
return stableSnapshot{hash: StructuralHash(tree), valid: true}
}
// routeTagKeys are the attribute keys whose value, when it ends with "Screen",
// marks a node as a route-level destination. Mirrors the companion's set.
var routeTagKeys = []string{
"resource-id", "resourceId", "testTag",
"identifier", "accessibilityIdentifier",
}
// CountRouteScreens counts nodes that carry a route-level destination tag (a
// route-tag attribute whose value ends with "Screen"). At most one tag is
// counted per node, matching the companion which breaks on the first match.
func CountRouteScreens(tree *hierarchy.Tree) int {
if tree == nil || tree.Root == nil {
return 0
}
return countRouteScreens(tree.Root)
}
func countRouteScreens(node *hierarchy.Node) int {
count := 0
for _, key := range routeTagKeys {
value, ok := node.Attributes[key]
if !ok {
continue
}
if strings.HasSuffix(value, "Screen") {
count++
break
}
}
for _, child := range node.Children {
count += countRouteScreens(child)
}
return count
}
// stableAttributeKeys are the identity attributes folded into the structural
// hash, in this exact order. The transient bounds attribute is deliberately
// excluded so a measure pass that shifts pixels without changing what is on
// screen does not extend the wait; structure (via tree shape) and text are
// included so a real content or layout-tree change does reset stability.
var stableAttributeKeys = []string{
"resource-id", "resourceId",
"class", "className",
"content-desc", "contentDescription", "accessibilityText",
"text",
"testTag", "identifier", "accessibilityIdentifier",
}
// StructuralHash walks the tree and concatenates only the stable identity
// attributes (excluding bounds), in tree order, wrapping each node in
// parentheses so tree shape is encoded. The result is compared by equality, so
// it is the hash itself rather than a digest of it.
func StructuralHash(tree *hierarchy.Tree) string {
if tree == nil || tree.Root == nil {
return ""
}
var builder strings.Builder
walkForStructuralHash(tree.Root, &builder)
return builder.String()
}
func walkForStructuralHash(node *hierarchy.Node, out *strings.Builder) {
out.WriteByte('(')
for _, key := range stableAttributeKeys {
value, ok := node.Attributes[key]
if !ok {
continue
}
out.WriteString(key)
out.WriteByte(':')
out.WriteString(value)
out.WriteByte('|')
}
for _, child := range node.Children {
walkForStructuralHash(child, out)
}
out.WriteByte(')')
}
+199
View File
@@ -0,0 +1,199 @@
package ioscompanion
import (
"context"
"fmt"
"testing"
"time"
"github.com/priyanshujain/sanderling/internal/hierarchy"
)
// fakeClock advances its internal time only when Sleep is called, so the poll
// loop runs instantly and deterministically without real sleeping.
type fakeClock struct {
now time.Time
}
func (c *fakeClock) Now() time.Time { return c.now }
func (c *fakeClock) Sleep(d time.Duration) { c.now = c.now.Add(d) }
func mustTree(t *testing.T, json string) *hierarchy.Tree {
t.Helper()
tree, err := hierarchy.Parse(json)
if err != nil {
t.Fatalf("parse hierarchy: %v", err)
}
return tree
}
// fetcher returns a fetch function yielding trees in sequence; once the
// sequence is exhausted the last tree repeats forever.
func fetcher(trees ...*hierarchy.Tree) func() *hierarchy.Tree {
index := 0
return func() *hierarchy.Tree {
tree := trees[index]
if index < len(trees)-1 {
index++
}
return tree
}
}
const singleScreenJSON = `{
"attributes": {"resource-id": "homeScreen", "text": "Home"},
"children": [
{"attributes": {"text": "Welcome", "bounds": "[0,0,100,50]"}, "children": []}
]
}`
// twoScreenJSON carries two route Screens (a navigation cross-fade), which must
// be treated as transitional.
const twoScreenJSON = `{
"attributes": {"resource-id": "rootView"},
"children": [
{"attributes": {"testTag": "homeScreen"}, "children": []},
{"attributes": {"testTag": "detailScreen"}, "children": []}
]
}`
func TestCountRouteScreens(t *testing.T) {
tests := []struct {
name string
json string
want int
}{
{"single screen", singleScreenJSON, 1},
{"two screens", twoScreenJSON, 2},
{"no screens", `{"attributes": {"text": "plain"}, "children": []}`, 0},
{
"one node only counts once across route keys",
`{"attributes": {"resource-id": "fooScreen", "testTag": "barScreen"}, "children": []}`,
1,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got := CountRouteScreens(mustTree(t, test.json))
if got != test.want {
t.Fatalf("CountRouteScreens = %d, want %d", got, test.want)
}
})
}
}
func TestStabilitySnapshotTreatsTwoScreensAsTransitional(t *testing.T) {
snap := StabilitySnapshot(mustTree(t, twoScreenJSON))
if snap.valid {
t.Fatalf("snapshot with two route Screens should be invalid (transitional)")
}
}
func TestStructuralHashIgnoresBoundsButNotTextOrStructure(t *testing.T) {
base := `{"attributes": {"text": "Hi", "bounds": "[0,0,10,10]"}, "children": [
{"attributes": {"text": "child", "bounds": "[0,0,5,5]"}, "children": []}
]}`
boundsShifted := `{"attributes": {"text": "Hi", "bounds": "[5,5,20,20]"}, "children": [
{"attributes": {"text": "child", "bounds": "[5,5,9,9]"}, "children": []}
]}`
textChanged := `{"attributes": {"text": "Bye", "bounds": "[0,0,10,10]"}, "children": [
{"attributes": {"text": "child", "bounds": "[0,0,5,5]"}, "children": []}
]}`
structureChanged := `{"attributes": {"text": "Hi", "bounds": "[0,0,10,10]"}, "children": [
{"attributes": {"text": "child", "bounds": "[0,0,5,5]"}, "children": []},
{"attributes": {"text": "extra"}, "children": []}
]}`
baseHash := StructuralHash(mustTree(t, base))
if got := StructuralHash(mustTree(t, boundsShifted)); got != baseHash {
t.Fatalf("bounds-only change must not alter hash:\n base=%q\n shift=%q", baseHash, got)
}
if got := StructuralHash(mustTree(t, textChanged)); got == baseHash {
t.Fatalf("text change must alter hash, both were %q", baseHash)
}
if got := StructuralHash(mustTree(t, structureChanged)); got == baseHash {
t.Fatalf("structure change must alter hash, both were %q", baseHash)
}
}
func TestPollUntilStableTwoScreensNeverStable(t *testing.T) {
clock := &fakeClock{now: time.Unix(0, 0)}
fetch := fetcher(mustTree(t, twoScreenJSON))
start := clock.Now()
PollUntilStable(context.Background(), clock, fetch)
elapsed := clock.Now().Sub(start)
if elapsed < StabilityPollCap {
t.Fatalf("a perpetually transitional UI must poll until the cap, elapsed=%v cap=%v", elapsed, StabilityPollCap)
}
}
func TestPollUntilStableReturnsAfterStreak(t *testing.T) {
clock := &fakeClock{now: time.Unix(0, 0)}
fetch := fetcher(mustTree(t, singleScreenJSON))
start := clock.Now()
PollUntilStable(context.Background(), clock, fetch)
elapsed := clock.Now().Sub(start)
// prior is sampled at t0, then equal snapshots accumulate the streak. The
// streak starts at the first matching tick and must reach MinStableStreak.
if elapsed > StabilityPollCap {
t.Fatalf("stable UI must settle before the cap, elapsed=%v", elapsed)
}
if elapsed < MinStableStreak {
t.Fatalf("must observe a full stable streak before returning, elapsed=%v streak=%v", elapsed, MinStableStreak)
}
}
func TestPollUntilStableResetsStreakOnMidStreakChange(t *testing.T) {
clock := &fakeClock{now: time.Unix(0, 0)}
stable := mustTree(t, singleScreenJSON)
churned := mustTree(t, `{"attributes": {"resource-id": "homeScreen", "text": "Loading"}, "children": []}`)
// Two stable ticks build part of a streak (250ms, 500ms accumulated since
// the streak start at the first match) then a changed tree resets it, after
// which a fresh full 750ms streak is required.
fetch := fetcher(stable, stable, stable, churned, stable)
start := clock.Now()
PollUntilStable(context.Background(), clock, fetch)
elapsed := clock.Now().Sub(start)
// The streak that succeeds begins after the churn, so total elapsed must
// exceed a single uninterrupted streak. Without a reset the loop would have
// returned far earlier.
if elapsed < MinStableStreak+3*StabilityPollInterval {
t.Fatalf("mid-streak change must reset and require a fresh streak, elapsed=%v", elapsed)
}
if elapsed > StabilityPollCap {
t.Fatalf("expected settle before cap once a clean streak completes, elapsed=%v", elapsed)
}
}
func TestPollUntilStableCapReturnsWhenNeverStable(t *testing.T) {
clock := &fakeClock{now: time.Unix(0, 0)}
// Each fetch yields a structurally different tree, so the streak never
// grows and only the cap can end the loop.
tick := 0
fetch := func() *hierarchy.Tree {
tick++
return mustTree(t, fmt.Sprintf(`{"attributes": {"text": "frame-%d"}, "children": []}`, tick))
}
start := clock.Now()
PollUntilStable(context.Background(), clock, fetch)
elapsed := clock.Now().Sub(start)
if elapsed < StabilityPollCap {
t.Fatalf("never-stable UI must poll up to the cap, elapsed=%v cap=%v", elapsed, StabilityPollCap)
}
if elapsed > StabilityPollCap+StabilityPollInterval {
t.Fatalf("must not overshoot the cap by more than one interval, elapsed=%v", elapsed)
}
}
func TestPollUntilStableHonorsContextCancellation(t *testing.T) {
clock := &fakeClock{now: time.Unix(0, 0)}
ctx, cancel := context.WithCancel(context.Background())
cancel()
fetch := fetcher(mustTree(t, twoScreenJSON))
start := clock.Now()
PollUntilStable(ctx, clock, fetch)
if elapsed := clock.Now().Sub(start); elapsed > StabilityPollInterval {
t.Fatalf("cancelled context must stop the loop promptly, elapsed=%v", elapsed)
}
}
@@ -0,0 +1,85 @@
//go:build withcompanion
package ioscompanion
import (
"context"
"os"
"os/exec"
"strings"
"testing"
"time"
)
// TestSmokeNewHierarchyScreenshot brings up the embedded companion against the
// booted simulator, reads one hierarchy and screenshot, closes, and asserts the
// companion child left no orphan behind. It is gated behind both the
// withcompanion build tag (so the binary is embedded) and an environment
// variable, so it never runs in the default suite.
func TestSmokeNewHierarchyScreenshot(t *testing.T) {
if os.Getenv("SANDERLING_IOS_INTEGRATION") == "" {
t.Skip("set SANDERLING_IOS_INTEGRATION=1 to run the companion smoke test")
}
udid := bootedUDID(t)
before := companionProcessCount(t, udid)
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Second)
defer cancel()
d, err := New(ctx, Options{UniqueDeviceIdentifier: udid, Output: os.Stderr})
if err != nil {
t.Fatalf("New: %v", err)
}
if _, err := d.Hierarchy(ctx); err != nil {
d.Close()
t.Fatalf("Hierarchy: %v", err)
}
if _, err := d.Screenshot(ctx); err != nil {
d.Close()
t.Fatalf("Screenshot: %v", err)
}
d.Close()
// Give the child a moment to exit after SIGTERM, then confirm no orphan.
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
if companionProcessCount(t, udid) <= before {
return
}
time.Sleep(200 * time.Millisecond)
}
t.Fatalf("companion process for %s outlived Close (orphan)", udid)
}
func bootedUDID(t *testing.T) string {
t.Helper()
output, err := exec.Command("xcrun", "simctl", "list", "devices", "booted").Output()
if err != nil {
t.Fatalf("simctl list: %v", err)
}
for _, line := range strings.Split(string(output), "\n") {
if start := strings.Index(line, "("); start >= 0 {
if end := strings.Index(line[start:], ")"); end > 0 {
candidate := line[start+1 : start+end]
if len(candidate) == 36 {
return candidate
}
}
}
}
t.Skip("no booted simulator available")
return ""
}
func companionProcessCount(t *testing.T, udid string) int {
t.Helper()
output, _ := exec.Command("pgrep", "-f", udid).Output()
count := 0
for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") {
if strings.TrimSpace(line) != "" {
count++
}
}
return count
}
@@ -0,0 +1,157 @@
package ioscompanion
import (
"context"
"errors"
"net"
"os/exec"
"syscall"
"testing"
"time"
"github.com/priyanshujain/sanderling/internal/driver/ioscompanion/transport"
)
// These tests cover the process-supervision logic (stopProcess, bringUp,
// respawnAndRedial) with real child processes and seamed transports, so the
// default suite exercises it without a simulator.
func TestStopProcessReapsChildOnSigterm(t *testing.T) {
child := exec.Command("sleep", "30")
if err := child.Start(); err != nil {
t.Fatalf("start child: %v", err)
}
start := time.Now()
stopProcess(child)
if elapsed := time.Since(start); elapsed > 5*time.Second {
t.Fatalf("stopProcess took %v; SIGTERM on sleep should reap promptly", elapsed)
}
if child.ProcessState == nil {
t.Fatal("child not reaped: ProcessState is nil")
}
if err := child.Process.Signal(syscall.Signal(0)); err == nil {
t.Fatal("child still signalable after stopProcess")
}
}
func TestStopProcessEscalatesToKillWhenSigtermIgnored(t *testing.T) {
previousGrace := shutdownGrace
shutdownGrace = 200 * time.Millisecond
defer func() { shutdownGrace = previousGrace }()
child := exec.Command("sh", "-c", `trap "" TERM; sleep 30`)
if err := child.Start(); err != nil {
t.Fatalf("start child: %v", err)
}
// Give the shell a beat to install its TERM trap.
time.Sleep(100 * time.Millisecond)
stopProcess(child)
if child.ProcessState == nil {
t.Fatal("child not reaped: ProcessState is nil")
}
if child.ProcessState.Success() {
t.Fatal("child should have died by signal, not exited cleanly")
}
}
func TestStopProcessHandlesNilChild(t *testing.T) {
stopProcess(nil)
stopProcess(&exec.Cmd{})
}
// TestRespawnAndRedialReplacesTransportAndChild drives the real restart path
// through the seams: the old transport must be closed, a fresh child spawned,
// and the fresh transport installed.
func TestRespawnAndRedialReplacesTransportAndChild(t *testing.T) {
t.Setenv("SANDERLING_SIMULATOR_COMPANION", "legacy")
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
go func() {
for {
connection, acceptErr := listener.Accept()
if acceptErr != nil {
return
}
_ = connection.Close()
}
}()
spawns := 0
dials := []*fakeCompanion{}
options := Options{
UniqueDeviceIdentifier: "FAKE-UDID",
pickAddress: func() (string, error) { return listener.Addr().String(), nil },
spawnChild: func(context.Context, string) (*exec.Cmd, error) {
spawns++
return &exec.Cmd{}, nil
},
dialCompanion: func(string) (transport.Companion, error) {
companion := &fakeCompanion{accessibilityJSON: "[]"}
dials = append(dials, companion)
return companion, nil
},
}
d, err := New(context.Background(), options)
if err != nil {
t.Fatal(err)
}
defer d.Close()
first := dials[0]
if err := d.respawnAndRedial(context.Background()); err != nil {
t.Fatalf("respawnAndRedial: %v", err)
}
if spawns != 2 {
t.Fatalf("spawns = %d, want 2 (initial bring-up plus restart)", spawns)
}
if len(dials) != 2 || d.companion != transport.Companion(dials[1]) {
t.Fatalf("restart must install the freshly dialed transport")
}
if indexOf(first.recorded(), "close") < 0 {
t.Fatal("restart must close the dead transport")
}
}
// TestBringUpStopsChildWhenDialFails proves a failed bring-up does not leak
// the child it spawned.
func TestBringUpStopsChildWhenDialFails(t *testing.T) {
t.Setenv("SANDERLING_SIMULATOR_COMPANION", "legacy")
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
go func() {
for {
connection, acceptErr := listener.Accept()
if acceptErr != nil {
return
}
_ = connection.Close()
}
}()
child := exec.Command("sleep", "30")
if err := child.Start(); err != nil {
t.Fatalf("start child: %v", err)
}
options := Options{
UniqueDeviceIdentifier: "FAKE-UDID",
pickAddress: func() (string, error) { return listener.Addr().String(), nil },
spawnChild: func(context.Context, string) (*exec.Cmd, error) {
return child, nil
},
dialCompanion: func(string) (transport.Companion, error) {
return nil, errors.New("dial refused")
},
}
if _, err := New(context.Background(), options); err == nil {
t.Fatal("New should fail when dial fails")
}
if child.ProcessState == nil {
t.Fatal("failed bring-up must reap the spawned child")
}
}
@@ -0,0 +1 @@
[{"AXFrame":"{{0, 0}, {402, 874}}","AXUniqueId":null,"frame":{"y":0,"x":0,"width":402,"height":874},"role_description":"application","AXLabel":"Folio","content_required":false,"type":"Application","title":null,"help":null,"custom_actions":[],"AXValue":null,"enabled":true,"role":"AXApplication","subrole":null},{"AXFrame":"{{20, 76}, {97.333335876464844, 24}}","AXUniqueId":null,"frame":{"y":76,"x":20,"width":97.333335876464844,"height":24},"role_description":"text","AXLabel":"Accounts","content_required":false,"type":"StaticText","title":null,"help":null,"custom_actions":[],"AXValue":null,"enabled":true,"role":"AXStaticText","subrole":null},{"AXFrame":"{{340, 71.333335876464844}, {48, 48}}","AXUniqueId":"LogoutButton","frame":{"y":71.333335876464844,"x":340,"width":48,"height":48},"role_description":"button","AXLabel":"Log out","content_required":false,"type":"Button","title":null,"help":null,"custom_actions":[],"AXValue":null,"enabled":true,"role":"AXButton","subrole":null},{"AXFrame":"{{20, 100}, {104, 14.333335876464844}}","AXUniqueId":null,"frame":{"y":100,"x":20,"width":104,"height":14.333335876464844},"role_description":"text","AXLabel":"[email protected]","content_required":false,"type":"StaticText","title":null,"help":null,"custom_actions":[],"AXValue":null,"enabled":true,"role":"AXStaticText","subrole":null},{"AXFrame":"{{131.33332824707031, 248.33332824707031}, {139.33332824707031, 17.999984741210938}}","AXUniqueId":null,"frame":{"y":248.33332824707031,"x":131.33332824707031,"width":139.33332824707031,"height":17.999984741210938},"role_description":"text","AXLabel":"No accounts yet","content_required":false,"type":"StaticText","title":null,"help":null,"custom_actions":[],"AXValue":null,"enabled":true,"role":"AXStaticText","subrole":null},{"AXFrame":"{{36, 272.33334350585938}, {330, 28.666656494140625}}","AXUniqueId":null,"frame":{"y":272.33334350585938,"x":36,"width":330,"height":28.666656494140625},"role_description":"text","AXLabel":"Create your first account to start tracking transactions.","content_required":false,"type":"StaticText","title":null,"help":null,"custom_actions":[],"AXValue":null,"enabled":true,"role":"AXStaticText","subrole":null},{"AXFrame":"{{20, 716.33331298828125}, {105.66666412353516, 14.33331298828125}}","AXUniqueId":null,"frame":{"y":716.33331298828125,"x":20,"width":105.66666412353516,"height":14.33331298828125},"role_description":"text","AXLabel":"TOTAL BALANCE","content_required":false,"type":"StaticText","title":null,"help":null,"custom_actions":[],"AXValue":null,"enabled":true,"role":"AXStaticText","subrole":null},{"AXFrame":"{{20, 730.66668701171875}, {85.333335876464844, 33.33331298828125}}","AXUniqueId":null,"frame":{"y":730.66668701171875,"x":20,"width":85.333335876464844,"height":33.33331298828125},"role_description":"text","AXLabel":"$0.00","content_required":false,"type":"StaticText","title":null,"help":null,"custom_actions":[],"AXValue":null,"enabled":true,"role":"AXStaticText","subrole":null},{"AXFrame":"{{307.66665649414062, 749.66668701171875}, {74.333343505859375, 14.33331298828125}}","AXUniqueId":null,"frame":{"y":749.66668701171875,"x":307.66665649414062,"width":74.333343505859375,"height":14.33331298828125},"role_description":"text","AXLabel":"0 accounts","content_required":false,"type":"StaticText","title":null,"help":null,"custom_actions":[],"AXValue":null,"enabled":true,"role":"AXStaticText","subrole":null},{"AXFrame":"{{20, 777}, {362, 48}}","AXUniqueId":"AddAccountButton","frame":{"y":777,"x":20,"width":362,"height":48},"role_description":"button","AXLabel":"+ Add account","content_required":false,"type":"Button","title":null,"help":null,"custom_actions":[],"AXValue":null,"enabled":true,"role":"AXButton","subrole":null}]
@@ -0,0 +1 @@
[{"AXFrame":"{{0, 0}, {0, 0}}","AXUniqueId":null,"frame":{"y":0,"x":0,"width":0,"height":0},"role_description":"application","AXLabel":null,"content_required":false,"type":"Application","title":null,"help":null,"custom_actions":[],"AXValue":null,"enabled":true,"role":"AXApplication","subrole":null},{"AXFrame":"{{20, 106}, {40.666667938232422, 14.333335876464844}}","AXUniqueId":null,"frame":{"y":106,"x":20,"width":40.666667938232422,"height":14.333335876464844},"role_description":"text","AXLabel":"EMAIL","content_required":false,"type":"StaticText","title":null,"help":null,"custom_actions":[],"AXValue":null,"enabled":true,"role":"AXStaticText","subrole":null},{"AXFrame":"{{34, 125.33333587646484}, {334, 48.000007629394531}}","AXUniqueId":"LoginEmail","frame":{"y":125.33333587646484,"x":34,"width":334,"height":48.000007629394531},"role_description":"text entry area","AXLabel":"Email","content_required":false,"type":"TextArea","title":null,"help":null,"custom_actions":[],"AXValue":null,"enabled":true,"role":"AXTextArea","subrole":null},{"AXFrame":"{{20, 186.33332824707031}, {65, 14.333328247070312}}","AXUniqueId":null,"frame":{"y":186.33332824707031,"x":20,"width":65,"height":14.333328247070312},"role_description":"text","AXLabel":"PASSWORD","content_required":false,"type":"StaticText","title":null,"help":null,"custom_actions":[],"AXValue":null,"enabled":true,"role":"AXStaticText","subrole":null},{"AXFrame":"{{34, 205.66667175292969}, {334, 48}}","AXUniqueId":"LoginPassword","frame":{"y":205.66667175292969,"x":34,"width":334,"height":48},"role_description":"text entry area","AXLabel":"Password","content_required":false,"type":"TextArea","title":null,"help":null,"custom_actions":[],"AXValue":null,"enabled":true,"role":"AXTextArea","subrole":null},{"AXFrame":"{{20, 266.66665649414062}, {362, 18}}","AXUniqueId":null,"frame":{"y":266.66665649414062,"x":20,"width":362,"height":18},"role_description":"text","AXLabel":"","content_required":false,"type":"StaticText","title":null,"help":null,"custom_actions":[],"AXValue":null,"enabled":true,"role":"AXStaticText","subrole":null},{"AXFrame":"{{20, 297.66665649414062}, {362, 48}}","AXUniqueId":"LoginSubmit","frame":{"y":297.66665649414062,"x":20,"width":362,"height":48},"role_description":"button","AXLabel":"Sign in","content_required":false,"type":"Button","title":null,"help":null,"custom_actions":[],"AXValue":null,"enabled":true,"role":"AXButton","subrole":null},{"AXFrame":"{{36, 392.66665649414062}, {119, 14.333343505859375}}","AXUniqueId":null,"frame":{"y":392.66665649414062,"x":36,"width":119,"height":14.333343505859375},"role_description":"text","AXLabel":"Demo credentials","content_required":false,"type":"StaticText","title":null,"help":null,"custom_actions":[],"AXValue":null,"enabled":true,"role":"AXStaticText","subrole":null},{"AXFrame":"{{36, 413}, {195, 18}}","AXUniqueId":null,"frame":{"y":413,"x":36,"width":195,"height":18},"role_description":"text","AXLabel":"email: [email protected]","content_required":false,"type":"StaticText","title":null,"help":null,"custom_actions":[],"AXValue":null,"enabled":true,"role":"AXStaticText","subrole":null},{"AXFrame":"{{36, 437}, {176.33332824707031, 18}}","AXUniqueId":null,"frame":{"y":437,"x":36,"width":176.33332824707031,"height":18},"role_description":"text","AXLabel":"password: ledger123","content_required":false,"type":"StaticText","title":null,"help":null,"custom_actions":[],"AXValue":null,"enabled":true,"role":"AXStaticText","subrole":null}]
@@ -0,0 +1,7 @@
[
{"type": "Window", "frame": {"x": 0, "y": 0, "width": 390, "height": 844}, "enabled": true},
{"type": "TextField", "AXUniqueId": "TxnNoteField", "AXValue": "", "AXLabel": "Note", "frame": {"x": 20, "y": 200, "width": 350, "height": 44}, "enabled": true},
{"type": "StaticText", "AXLabel": "Allow Pasting", "frame": {"x": 95, "y": 380, "width": 200, "height": 22}, "enabled": true},
{"type": "Button", "AXLabel": "Don’t Allow Paste", "frame": {"x": 40, "y": 440, "width": 140, "height": 50}, "enabled": true},
{"type": "Button", "AXLabel": "Allow Paste", "frame": {"x": 210, "y": 440, "width": 140, "height": 50}, "enabled": true}
]
@@ -0,0 +1,295 @@
package transport
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"fmt"
"io"
"os"
"path/filepath"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
pb "github.com/priyanshujain/sanderling/internal/driver/ioscompanion/companionpb"
)
// maxReceiveBytes is generous because screenshots can be large.
const maxReceiveBytes = 64 * 1024 * 1024
type grpcCompanion struct {
conn *grpc.ClientConn
client pb.CompanionServiceClient
}
// Dial connects to the companion listening at address and returns a Companion.
func Dial(address string) (Companion, error) {
conn, err := grpc.NewClient(address,
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(maxReceiveBytes)))
if err != nil {
return nil, err
}
return &grpcCompanion{conn: conn, client: pb.NewCompanionServiceClient(conn)}, nil
}
func (c *grpcCompanion) Close() error { return c.conn.Close() }
func (c *grpcCompanion) AccessibilityInfo(ctx context.Context) (string, error) {
resp, err := c.client.AccessibilityInfo(ctx, &pb.AccessibilityInfoRequest{
Format: pb.AccessibilityInfoRequest_LEGACY,
})
if err != nil {
return "", err
}
return resp.GetJson(), nil
}
func (c *grpcCompanion) Describe(ctx context.Context) (ScreenDescription, error) {
resp, err := c.client.Describe(ctx, &pb.TargetDescriptionRequest{})
if err != nil {
return ScreenDescription{}, err
}
return screenDescriptionFrom(resp), nil
}
// screenDescriptionFrom extracts the point dimensions and scale from a describe
// response. The generated getters are nil-safe, so a response missing the
// nested messages yields a zero-valued ScreenDescription rather than panicking.
func screenDescriptionFrom(resp *pb.TargetDescriptionResponse) ScreenDescription {
dimensions := resp.GetTargetDescription().GetScreenDimensions()
return ScreenDescription{
WidthPoints: int(dimensions.GetWidthPoints()),
HeightPoints: int(dimensions.GetHeightPoints()),
Scale: dimensions.GetDensity(),
}
}
func (c *grpcCompanion) SendHID(ctx context.Context, events ...HIDEvent) error {
stream, err := c.client.Hid(ctx)
if err != nil {
return err
}
for _, e := range events {
message, err := hidEventToProto(e)
if err != nil {
return err
}
if err := stream.Send(message); err != nil {
return err
}
}
_, err = stream.CloseAndRecv()
return err
}
// hidEventToProto encodes a neutral HID event as this companion's wire event.
// The companion measures delays and swipe durations in seconds.
func hidEventToProto(e HIDEvent) (*pb.HIDEvent, error) {
switch e.Kind {
case HIDKindTouchDown:
return touchProto(e.X, e.Y, pb.HIDEvent_DOWN), nil
case HIDKindTouchUp:
return touchProto(e.X, e.Y, pb.HIDEvent_UP), nil
case HIDKindKeyDown:
return keyProto(e.Usage, pb.HIDEvent_DOWN), nil
case HIDKindKeyUp:
return keyProto(e.Usage, pb.HIDEvent_UP), nil
case HIDKindDelay:
return &pb.HIDEvent{Event: &pb.HIDEvent_Delay{
Delay: &pb.HIDEvent_HIDDelay{Duration: e.Milliseconds / 1000.0},
}}, nil
case HIDKindSwipe:
return &pb.HIDEvent{Event: &pb.HIDEvent_Swipe{Swipe: &pb.HIDEvent_HIDSwipe{
Start: &pb.Point{X: e.FromX, Y: e.FromY},
End: &pb.Point{X: e.ToX, Y: e.ToY},
Duration: e.Seconds,
}}}, nil
}
return nil, fmt.Errorf("unknown HID event kind %d", e.Kind)
}
func touchProto(x, y float64, direction pb.HIDEvent_HIDDirection) *pb.HIDEvent {
return &pb.HIDEvent{Event: &pb.HIDEvent_Press{Press: &pb.HIDEvent_HIDPress{
Direction: direction,
Action: &pb.HIDEvent_HIDPressAction{Action: &pb.HIDEvent_HIDPressAction_Touch{
Touch: &pb.HIDEvent_HIDTouch{Point: &pb.Point{X: x, Y: y}},
}},
}}}
}
func keyProto(usage uint32, direction pb.HIDEvent_HIDDirection) *pb.HIDEvent {
return &pb.HIDEvent{Event: &pb.HIDEvent_Press{Press: &pb.HIDEvent_HIDPress{
Direction: direction,
Action: &pb.HIDEvent_HIDPressAction{Action: &pb.HIDEvent_HIDPressAction_Key{
Key: &pb.HIDEvent_HIDKey{Keycode: uint64(usage)},
}},
}}}
}
func (c *grpcCompanion) Screenshot(ctx context.Context) ([]byte, string, error) {
resp, err := c.client.Screenshot(ctx, &pb.ScreenshotRequest{})
if err != nil {
return nil, "", err
}
return resp.GetImageData(), resp.GetImageFormat(), nil
}
func (c *grpcCompanion) Launch(ctx context.Context, bundleID string, foregroundIfRunning bool) error {
stream, err := c.client.Launch(ctx)
if err != nil {
return err
}
err = stream.Send(&pb.LaunchRequest{Control: &pb.LaunchRequest_Start_{Start: &pb.LaunchRequest_Start{
BundleId: bundleID,
ForegroundIfRunning: foregroundIfRunning,
}}})
if err != nil {
return err
}
if _, err := stream.Recv(); err != nil && err != io.EOF {
return err
}
return stream.CloseSend()
}
func (c *grpcCompanion) Terminate(ctx context.Context, bundleID string) error {
_, err := c.client.Terminate(ctx, &pb.TerminateRequest{BundleId: bundleID})
return err
}
func (c *grpcCompanion) Uninstall(ctx context.Context, bundleID string) error {
_, err := c.client.Uninstall(ctx, &pb.UninstallRequest{BundleId: bundleID})
return err
}
func (c *grpcCompanion) ListApps(ctx context.Context) ([]InstalledApp, error) {
resp, err := c.client.ListApps(ctx, &pb.ListAppsRequest{})
if err != nil {
return nil, err
}
apps := make([]InstalledApp, 0, len(resp.GetApps()))
for _, a := range resp.GetApps() {
apps = append(apps, InstalledApp{
BundleID: a.GetBundleId(),
Name: a.GetName(),
InstallType: a.GetInstallType(),
ProcessState: processStateFromProto(a.GetProcessState()),
Debuggable: a.GetDebuggable(),
ProcessIdentifier: a.GetProcessIdentifier(),
})
}
return apps, nil
}
// installChunkBytes keeps each install payload frame comfortably under the
// companion's 16MiB incoming-message cap.
const installChunkBytes = 4 * 1024 * 1024
func (c *grpcCompanion) Install(ctx context.Context, appPath string) error {
info, err := os.Stat(appPath)
if err != nil {
return err
}
if !info.IsDir() {
return fmt.Errorf("install: %s is not an app bundle directory", appPath)
}
stream, err := c.client.Install(ctx)
if err != nil {
return err
}
// First message sets the destination, then the payload carries the bundle
// as a gzip-compressed tar archive the companion unpacks.
if err := stream.Send(&pb.InstallRequest{Value: &pb.InstallRequest_Destination_{
Destination: pb.InstallRequest_APP,
}}); err != nil {
return err
}
archive, err := tarGzipDirectory(appPath)
if err != nil {
return err
}
// The companion caps incoming messages at 16MiB, so the archive streams in
// chunks; the companion concatenates consecutive data payloads.
for _, chunk := range payloadChunks(archive, installChunkBytes) {
if err := stream.Send(&pb.InstallRequest{Value: &pb.InstallRequest_Payload{
Payload: &pb.Payload{Source: &pb.Payload_Data{Data: chunk}},
}}); err != nil {
return err
}
}
if err := stream.CloseSend(); err != nil {
return err
}
for {
if _, err := stream.Recv(); err != nil {
if err == io.EOF {
return nil
}
return err
}
}
}
// payloadChunks splits data into consecutive slices of at most chunkBytes.
func payloadChunks(data []byte, chunkBytes int) [][]byte {
var chunks [][]byte
for offset := 0; offset < len(data); offset += chunkBytes {
end := min(offset+chunkBytes, len(data))
chunks = append(chunks, data[offset:end])
}
return chunks
}
// tarGzipDirectory packs dir into a gzip-compressed tar archive. Entry paths are
// relative to the parent of dir so the bundle directory itself is preserved.
func tarGzipDirectory(dir string) ([]byte, error) {
var buffer bytes.Buffer
gzipWriter := gzip.NewWriter(&buffer)
tarWriter := tar.NewWriter(gzipWriter)
base := filepath.Dir(dir)
walkErr := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
relative, err := filepath.Rel(base, path)
if err != nil {
return err
}
header, err := tar.FileInfoHeader(info, "")
if err != nil {
return err
}
header.Name = filepath.ToSlash(relative)
if err := tarWriter.WriteHeader(header); err != nil {
return err
}
if info.IsDir() {
return nil
}
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
_, err = io.Copy(tarWriter, file)
return err
})
if walkErr != nil {
return nil, walkErr
}
if err := tarWriter.Close(); err != nil {
return nil, err
}
if err := gzipWriter.Close(); err != nil {
return nil, err
}
return buffer.Bytes(), nil
}
@@ -0,0 +1,158 @@
package transport
import (
"archive/tar"
"bytes"
"compress/gzip"
"io"
"os"
"path/filepath"
"testing"
pb "github.com/priyanshujain/sanderling/internal/driver/ioscompanion/companionpb"
)
// grpcCompanion must satisfy Companion.
var _ Companion = (*grpcCompanion)(nil)
func TestDialReturnsCompanion(t *testing.T) {
companion, err := Dial("127.0.0.1:0")
if err != nil {
t.Fatalf("Dial: %v", err)
}
defer companion.Close()
if companion == nil {
t.Fatal("Dial returned nil companion")
}
}
func TestProcessStateFromProto(t *testing.T) {
cases := []struct {
in pb.InstalledAppInfo_AppProcessState
want ProcessState
}{
{pb.InstalledAppInfo_RUNNING, ProcessStateRunning},
{pb.InstalledAppInfo_NOT_RUNNING, ProcessStateNotRunning},
{pb.InstalledAppInfo_UNKNOWN, ProcessStateUnknown},
}
for _, c := range cases {
if got := processStateFromProto(c.in); got != c.want {
t.Errorf("processStateFromProto(%v) = %v, want %v", c.in, got, c.want)
}
}
}
func TestScreenDescriptionFrom(t *testing.T) {
resp := &pb.TargetDescriptionResponse{
TargetDescription: &pb.TargetDescription{
ScreenDimensions: &pb.ScreenDimensions{
Width: 828,
Height: 1792,
Density: 2,
WidthPoints: 414,
HeightPoints: 896,
},
},
}
got := screenDescriptionFrom(resp)
want := ScreenDescription{WidthPoints: 414, HeightPoints: 896, Scale: 2}
if got != want {
t.Errorf("screenDescriptionFrom = %+v, want %+v", got, want)
}
}
func TestScreenDescriptionFromNilSafe(t *testing.T) {
if got := screenDescriptionFrom(&pb.TargetDescriptionResponse{}); got != (ScreenDescription{}) {
t.Errorf("screenDescriptionFrom(empty) = %+v, want zero", got)
}
}
func TestTarGzipDirectory(t *testing.T) {
root := t.TempDir()
bundle := filepath.Join(root, "Sample.app")
if err := os.MkdirAll(filepath.Join(bundle, "PlugIns"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(bundle, "Info.plist"), []byte("plist"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(bundle, "PlugIns", "ext"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
archive, err := tarGzipDirectory(bundle)
if err != nil {
t.Fatalf("tarGzipDirectory: %v", err)
}
names := tarEntryNames(t, archive)
want := map[string]bool{
"Sample.app": true,
"Sample.app/Info.plist": true,
"Sample.app/PlugIns": true,
"Sample.app/PlugIns/ext": true,
}
for name := range want {
if !names[name] {
t.Errorf("archive missing entry %q; got %v", name, names)
}
}
}
func tarEntryNames(t *testing.T, archive []byte) map[string]bool {
t.Helper()
gzipReader, err := gzip.NewReader(bytes.NewReader(archive))
if err != nil {
t.Fatalf("gzip reader: %v", err)
}
defer gzipReader.Close()
tarReader := tar.NewReader(gzipReader)
names := map[string]bool{}
for {
header, err := tarReader.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("tar next: %v", err)
}
names[filepath.ToSlash(filepath.Clean(header.Name))] = true
}
return names
}
func TestPayloadChunks(t *testing.T) {
cases := []struct {
name string
dataLength int
chunkBytes int
wantSizes []int
}{
{name: "empty", dataLength: 0, chunkBytes: 4, wantSizes: nil},
{name: "under one chunk", dataLength: 3, chunkBytes: 4, wantSizes: []int{3}},
{name: "exact multiple", dataLength: 8, chunkBytes: 4, wantSizes: []int{4, 4}},
{name: "remainder", dataLength: 10, chunkBytes: 4, wantSizes: []int{4, 4, 2}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
data := make([]byte, c.dataLength)
for i := range data {
data[i] = byte(i)
}
chunks := payloadChunks(data, c.chunkBytes)
if len(chunks) != len(c.wantSizes) {
t.Fatalf("got %d chunks, want %d", len(chunks), len(c.wantSizes))
}
var rejoined []byte
for i, chunk := range chunks {
if len(chunk) != c.wantSizes[i] {
t.Fatalf("chunk %d size %d, want %d", i, len(chunk), c.wantSizes[i])
}
rejoined = append(rejoined, chunk...)
}
if !bytes.Equal(rejoined, data) {
t.Fatal("rejoined chunks differ from input")
}
})
}
}
@@ -0,0 +1,63 @@
package transport
// HIDEventKind discriminates the neutral HID event variants.
type HIDEventKind int
const (
HIDKindTouchDown HIDEventKind = iota
HIDKindTouchUp
HIDKindKeyDown
HIDKindKeyUp
HIDKindDelay
HIDKindSwipe
)
// HIDEvent is one input event in a HID stream, expressed in transport-neutral
// terms so each companion transport encodes it for its own wire format. Build
// one with a builder below; only the fields for the event's kind are set.
type HIDEvent struct {
Kind HIDEventKind
// X, Y is the touch point for TouchDown and TouchUp.
X, Y float64
// Usage is the USB HID usage identifier for KeyDown and KeyUp.
Usage uint32
// Milliseconds is the pause length for Delay.
Milliseconds float64
// FromX through Seconds describe a Swipe.
FromX, FromY float64
ToX, ToY float64
Seconds float64
}
// TouchDown presses a finger down at screen point (x, y).
func TouchDown(x, y float64) HIDEvent { return HIDEvent{Kind: HIDKindTouchDown, X: x, Y: y} }
// TouchUp lifts the finger at screen point (x, y).
func TouchUp(x, y float64) HIDEvent { return HIDEvent{Kind: HIDKindTouchUp, X: x, Y: y} }
// KeyDown presses the key with the given USB HID usage identifier.
func KeyDown(usage uint32) HIDEvent { return HIDEvent{Kind: HIDKindKeyDown, Usage: usage} }
// KeyUp releases the key with the given USB HID usage identifier.
func KeyUp(usage uint32) HIDEvent { return HIDEvent{Kind: HIDKindKeyUp, Usage: usage} }
// Delay pauses the HID stream for the given duration.
func Delay(milliseconds float64) HIDEvent {
return HIDEvent{Kind: HIDKindDelay, Milliseconds: milliseconds}
}
// SwipeEvent drags from (fromX, fromY) to (toX, toY) over durationSeconds.
func SwipeEvent(fromX, fromY, toX, toY float64, durationSeconds float64) HIDEvent {
return HIDEvent{
Kind: HIDKindSwipe,
FromX: fromX,
FromY: fromY,
ToX: toX,
ToY: toY,
Seconds: durationSeconds,
}
}
@@ -0,0 +1,137 @@
package transport
import (
"testing"
pb "github.com/priyanshujain/sanderling/internal/driver/ioscompanion/companionpb"
)
func TestBuildersSetKindAndFields(t *testing.T) {
cases := []struct {
name string
event HIDEvent
want HIDEvent
}{
{"touch down", TouchDown(12, 34), HIDEvent{Kind: HIDKindTouchDown, X: 12, Y: 34}},
{"touch up", TouchUp(12, 34), HIDEvent{Kind: HIDKindTouchUp, X: 12, Y: 34}},
{"key down", KeyDown(225), HIDEvent{Kind: HIDKindKeyDown, Usage: 225}},
{"key up", KeyUp(225), HIDEvent{Kind: HIDKindKeyUp, Usage: 225}},
{"delay", Delay(250), HIDEvent{Kind: HIDKindDelay, Milliseconds: 250}},
{"swipe", SwipeEvent(1, 2, 3, 4, 0.5), HIDEvent{
Kind: HIDKindSwipe, FromX: 1, FromY: 2, ToX: 3, ToY: 4, Seconds: 0.5,
}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if c.event != c.want {
t.Errorf("event = %+v, want %+v", c.event, c.want)
}
})
}
}
func TestTouchEventsToProto(t *testing.T) {
cases := []struct {
name string
event HIDEvent
direction pb.HIDEvent_HIDDirection
}{
{"down", TouchDown(12, 34), pb.HIDEvent_DOWN},
{"up", TouchUp(12, 34), pb.HIDEvent_UP},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
message, err := hidEventToProto(c.event)
if err != nil {
t.Fatalf("hidEventToProto: %v", err)
}
press := message.GetPress()
if press == nil {
t.Fatalf("expected press event, got %#v", message.GetEvent())
}
if press.GetDirection() != c.direction {
t.Errorf("direction = %v, want %v", press.GetDirection(), c.direction)
}
touch := press.GetAction().GetTouch()
if touch == nil {
t.Fatalf("expected touch action")
}
if got := touch.GetPoint(); got.GetX() != 12 || got.GetY() != 34 {
t.Errorf("point = (%v, %v), want (12, 34)", got.GetX(), got.GetY())
}
})
}
}
func TestKeyEventsToProto(t *testing.T) {
cases := []struct {
name string
event HIDEvent
direction pb.HIDEvent_HIDDirection
}{
{"down", KeyDown(225), pb.HIDEvent_DOWN},
{"up", KeyUp(225), pb.HIDEvent_UP},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
message, err := hidEventToProto(c.event)
if err != nil {
t.Fatalf("hidEventToProto: %v", err)
}
press := message.GetPress()
if press == nil {
t.Fatalf("expected press event")
}
if press.GetDirection() != c.direction {
t.Errorf("direction = %v, want %v", press.GetDirection(), c.direction)
}
key := press.GetAction().GetKey()
if key == nil {
t.Fatalf("expected key action")
}
if key.GetKeycode() != 225 {
t.Errorf("keycode = %d, want 225", key.GetKeycode())
}
})
}
}
func TestDelayToProtoConvertsToSeconds(t *testing.T) {
message, err := hidEventToProto(Delay(250))
if err != nil {
t.Fatalf("hidEventToProto: %v", err)
}
delay := message.GetDelay()
if delay == nil {
t.Fatalf("expected delay event")
}
if delay.GetDuration() != 0.25 {
t.Errorf("duration = %v seconds, want 0.25", delay.GetDuration())
}
}
func TestSwipeEventToProto(t *testing.T) {
message, err := hidEventToProto(SwipeEvent(1, 2, 3, 4, 0.5))
if err != nil {
t.Fatalf("hidEventToProto: %v", err)
}
swipe := message.GetSwipe()
if swipe == nil {
t.Fatalf("expected swipe event")
}
if s := swipe.GetStart(); s.GetX() != 1 || s.GetY() != 2 {
t.Errorf("start = (%v, %v), want (1, 2)", s.GetX(), s.GetY())
}
if e := swipe.GetEnd(); e.GetX() != 3 || e.GetY() != 4 {
t.Errorf("end = (%v, %v), want (3, 4)", e.GetX(), e.GetY())
}
if swipe.GetDuration() != 0.5 {
t.Errorf("duration = %v, want 0.5", swipe.GetDuration())
}
}
func TestUnknownKindToProtoErrors(t *testing.T) {
if _, err := hidEventToProto(HIDEvent{Kind: HIDEventKind(99)}); err == nil {
t.Fatal("expected error for unknown event kind")
}
}
@@ -0,0 +1,105 @@
package transport
import (
"context"
"encoding/json"
"net"
"os"
"os/exec"
"strconv"
"strings"
"testing"
"time"
)
// TestIntegrationAccessibilityInfo spawns a local companion against a booted
// simulator and exercises a real AccessibilityInfo call. It is gated behind
// SANDERLING_IOS_INTEGRATION so it never runs by default.
func TestIntegrationAccessibilityInfo(t *testing.T) {
if os.Getenv("SANDERLING_IOS_INTEGRATION") == "" {
t.Skip("set SANDERLING_IOS_INTEGRATION=1 to run the integration smoke test")
}
udid := bootedSimulatorUDID(t)
port := freePort(t)
companionBinary := "/opt/homebrew/bin/idb_companion"
command := exec.Command(companionBinary, "--udid", udid, "--grpc-port", strconv.Itoa(port))
if err := command.Start(); err != nil {
t.Fatalf("start companion: %v", err)
}
defer func() {
_ = command.Process.Kill()
_, _ = command.Process.Wait()
}()
address := net.JoinHostPort("127.0.0.1", strconv.Itoa(port))
waitForListener(t, address)
companion, err := Dial(address)
if err != nil {
t.Fatalf("Dial: %v", err)
}
defer companion.Close()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
info, err := companion.AccessibilityInfo(ctx)
if err != nil {
t.Fatalf("AccessibilityInfo: %v", err)
}
if strings.TrimSpace(info) == "" {
t.Fatal("AccessibilityInfo returned empty json")
}
}
func bootedSimulatorUDID(t *testing.T) string {
t.Helper()
output, err := exec.Command("xcrun", "simctl", "list", "devices", "booted", "--json").Output()
if err != nil {
t.Fatalf("simctl list: %v", err)
}
var parsed struct {
Devices map[string][]struct {
UDID string `json:"udid"`
State string `json:"state"`
} `json:"devices"`
}
if err := json.Unmarshal(output, &parsed); err != nil {
t.Fatalf("parse simctl json: %v", err)
}
for _, devices := range parsed.Devices {
for _, device := range devices {
if device.State == "Booted" {
return device.UDID
}
}
}
t.Skip("no booted simulator available")
return ""
}
func freePort(t *testing.T) int {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("reserve port: %v", err)
}
port := listener.Addr().(*net.TCPAddr).Port
_ = listener.Close()
return port
}
func waitForListener(t *testing.T, address string) {
t.Helper()
deadline := time.Now().Add(20 * time.Second)
for time.Now().Before(deadline) {
conn, err := net.DialTimeout("tcp", address, time.Second)
if err == nil {
_ = conn.Close()
return
}
time.Sleep(200 * time.Millisecond)
}
t.Fatalf("companion did not listen on %s", address)
}
@@ -0,0 +1,342 @@
package transport
import (
"bufio"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net"
"os"
"os/exec"
"sync"
"time"
)
// deadlineImmediate is a deadline already in the past, used to interrupt a
// blocked read or write when the context is cancelled.
var deadlineImmediate = time.Unix(1, 0)
// runnerCompanion speaks newline-delimited JSON over a single persistent TCP
// connection to the in-simulator runner. One request is in flight at a time:
// the mutex serializes call/response pairs over the shared connection.
type runnerCompanion struct {
uniqueDeviceIdentifier string
bundleID string
address string
mutex sync.Mutex
conn net.Conn
reader *bufio.Reader
nextID int
// dirty marks the connection desynced: a call was interrupted before its
// response was read, so the next call reconnects to the still-running
// server instead of misreading the stale response.
dirty bool
}
// DialRunner opens one persistent TCP connection to the simulator runner at
// address and returns a Companion that also implements TextEditor. The
// uniqueDeviceIdentifier targets simctl shell-outs; bundleID is the app the
// snapshot and app-state queries default to.
func DialRunner(address, uniqueDeviceIdentifier, bundleID string) (Companion, error) {
conn, err := net.Dial("tcp", address)
if err != nil {
return nil, fmt.Errorf("runner transport: %w: dial %s: %v", ErrCompanionUnavailable, address, err)
}
return &runnerCompanion{
uniqueDeviceIdentifier: uniqueDeviceIdentifier,
bundleID: bundleID,
address: address,
conn: conn,
reader: bufio.NewReader(conn),
}, nil
}
func (c *runnerCompanion) Close() error { return c.conn.Close() }
// runnerRequest and runnerResponse are the wire envelopes. A response carries
// either result or error, never both.
type runnerRequest struct {
ID int `json:"id"`
Method string `json:"method"`
Params map[string]any `json:"params"`
}
type runnerResponse struct {
ID int `json:"id"`
Result json.RawMessage `json:"result"`
Error string `json:"error"`
}
// call sends one request and returns its result payload. Transport-level
// failures wrap ErrCompanionUnavailable; a server-reported error does not.
func (c *runnerCompanion) call(ctx context.Context, method string, params map[string]any) (json.RawMessage, error) {
c.mutex.Lock()
defer c.mutex.Unlock()
if c.dirty {
if err := c.reconnect(); err != nil {
return nil, fmt.Errorf("runner transport: %w: reconnect: %v", ErrCompanionUnavailable, err)
}
}
if params == nil {
params = map[string]any{}
}
c.nextID++
id := c.nextID
// A blocked read or write cannot observe context cancellation directly, so
// AfterFunc trips an immediate deadline to interrupt it. The deadline is
// cleared once the call returns so the next call starts fresh.
stop := context.AfterFunc(ctx, func() {
c.conn.SetDeadline(deadlineImmediate)
})
// Clearing the deadline must happen after stop() so a late-firing AfterFunc
// cannot re-arm the deadline once the call has completed. Defers run LIFO.
defer c.conn.SetDeadline(time.Time{})
defer stop()
if deadline, ok := ctx.Deadline(); ok {
c.conn.SetDeadline(deadline)
}
payload, err := json.Marshal(runnerRequest{ID: id, Method: method, Params: params})
if err != nil {
return nil, fmt.Errorf("runner transport: %w: marshal %s request: %v", ErrCompanionUnavailable, method, err)
}
payload = append(payload, '\n')
if _, err := c.conn.Write(payload); err != nil {
return nil, c.wrapTransport(ctx, "write", method, err)
}
line, err := c.reader.ReadBytes('\n')
if err != nil {
return nil, c.wrapTransport(ctx, "read", method, err)
}
var response runnerResponse
if err := json.Unmarshal(line, &response); err != nil {
return nil, fmt.Errorf("runner transport: %w: decode %s response: %v", ErrCompanionUnavailable, method, err)
}
if response.ID != id {
return nil, fmt.Errorf("runner transport: %w: response id %d does not match request id %d", ErrCompanionUnavailable, response.ID, id)
}
if response.Error != "" {
return nil, fmt.Errorf("runner %s: %s", method, response.Error)
}
return response.Result, nil
}
// wrapTransport classifies a read/write failure. A caller-imposed cancel or
// deadline is the caller's slowness budget, not a connection loss, so it does
// not carry the unavailable sentinel: a child restart would not make the call
// faster. The connection's own deadline is only ever set from the caller's
// context, so a deadline-exceeded network error is the same budget expiry
// even when it beats the context's done flag by a hair. Either way the
// connection is desynced and reconnects on the next call.
func (c *runnerCompanion) wrapTransport(ctx context.Context, stage, method string, err error) error {
c.dirty = true
if ctxErr := ctx.Err(); ctxErr != nil {
return fmt.Errorf("runner %s interrupted (%s): %w", method, stage, ctxErr)
}
if errors.Is(err, os.ErrDeadlineExceeded) {
return fmt.Errorf("runner %s interrupted (%s): %w", method, stage, err)
}
return fmt.Errorf("runner transport: %w: %s %s: %v", ErrCompanionUnavailable, stage, method, err)
}
// reconnect replaces the desynced connection with a fresh one to the same
// still-running server.
func (c *runnerCompanion) reconnect() error {
_ = c.conn.Close()
conn, err := net.Dial("tcp", c.address)
if err != nil {
return err
}
c.conn = conn
c.reader = bufio.NewReader(conn)
c.dirty = false
return nil
}
func (c *runnerCompanion) AccessibilityInfo(ctx context.Context) (string, error) {
result, err := c.call(ctx, "snapshot", map[string]any{"bundleId": c.bundleID})
if err != nil {
return "", err
}
var payload struct {
Elements []json.RawMessage `json:"elements"`
}
if err := json.Unmarshal(result, &payload); err != nil {
return "", fmt.Errorf("runner transport: %w: decode snapshot elements: %v", ErrCompanionUnavailable, err)
}
elements, err := json.Marshal(payload.Elements)
if err != nil {
return "", fmt.Errorf("runner transport: %w: re-marshal snapshot elements: %v", ErrCompanionUnavailable, err)
}
return string(elements), nil
}
func (c *runnerCompanion) Describe(ctx context.Context) (ScreenDescription, error) {
result, err := c.call(ctx, "describe", nil)
if err != nil {
return ScreenDescription{}, err
}
var payload struct {
WidthPoints int `json:"widthPoints"`
HeightPoints int `json:"heightPoints"`
Scale float64 `json:"scale"`
}
if err := json.Unmarshal(result, &payload); err != nil {
return ScreenDescription{}, fmt.Errorf("runner transport: %w: decode describe response: %v", ErrCompanionUnavailable, err)
}
return ScreenDescription{
WidthPoints: payload.WidthPoints,
HeightPoints: payload.HeightPoints,
Scale: payload.Scale,
}, nil
}
func (c *runnerCompanion) SendHID(ctx context.Context, events ...HIDEvent) error {
encoded := make([]map[string]any, 0, len(events))
for _, event := range events {
object, err := hidEventToObject(event)
if err != nil {
return err
}
encoded = append(encoded, object)
}
_, err := c.call(ctx, "gesture", map[string]any{"events": encoded})
return err
}
// hidEventToObject encodes a neutral HID event as a runner gesture event. The
// runner edits text natively, so keyboard HID events are rejected with an
// ordinary error rather than a connection-level one.
func hidEventToObject(event HIDEvent) (map[string]any, error) {
switch event.Kind {
case HIDKindTouchDown:
return map[string]any{"kind": "touchDown", "x": event.X, "y": event.Y}, nil
case HIDKindTouchUp:
return map[string]any{"kind": "touchUp", "x": event.X, "y": event.Y}, nil
case HIDKindDelay:
return map[string]any{"kind": "delay", "milliseconds": event.Milliseconds}, nil
case HIDKindSwipe:
return map[string]any{
"kind": "swipe",
"fromX": event.FromX,
"fromY": event.FromY,
"toX": event.ToX,
"toY": event.ToY,
"seconds": event.Seconds,
}, nil
case HIDKindKeyDown, HIDKindKeyUp:
return nil, errors.New("runner companion does not synthesize keyboard HID events; route text through the text editor")
}
return nil, fmt.Errorf("unknown HID event kind %d", event.Kind)
}
func (c *runnerCompanion) Screenshot(ctx context.Context) ([]byte, string, error) {
result, err := c.call(ctx, "screenshot", nil)
if err != nil {
return nil, "", err
}
var payload struct {
PNGBase64 string `json:"pngBase64"`
}
if err := json.Unmarshal(result, &payload); err != nil {
return nil, "", fmt.Errorf("runner transport: %w: decode screenshot response: %v", ErrCompanionUnavailable, err)
}
data, err := base64.StdEncoding.DecodeString(payload.PNGBase64)
if err != nil {
return nil, "", fmt.Errorf("runner transport: %w: decode screenshot png: %v", ErrCompanionUnavailable, err)
}
return data, "png", nil
}
func (c *runnerCompanion) Launch(ctx context.Context, bundleID string, foregroundIfRunning bool) error {
_, err := c.call(ctx, "launch", map[string]any{
"bundleId": bundleID,
"foregroundIfRunning": foregroundIfRunning,
})
return err
}
func (c *runnerCompanion) Terminate(ctx context.Context, bundleID string) error {
_, err := c.call(ctx, "terminate", map[string]any{"bundleId": bundleID})
return err
}
func (c *runnerCompanion) ListApps(ctx context.Context) ([]InstalledApp, error) {
result, err := c.call(ctx, "appState", map[string]any{"bundleId": c.bundleID})
if err != nil {
return nil, err
}
var payload struct {
State string `json:"state"`
}
if err := json.Unmarshal(result, &payload); err != nil {
return nil, fmt.Errorf("runner transport: %w: decode appState response: %v", ErrCompanionUnavailable, err)
}
return []InstalledApp{{
BundleID: c.bundleID,
InstallType: "user",
ProcessState: processStateFromAppState(payload.State),
}}, nil
}
func processStateFromAppState(state string) ProcessState {
switch state {
case "foreground", "background":
return ProcessStateRunning
case "notRunning":
return ProcessStateNotRunning
default:
return ProcessStateUnknown
}
}
// Install and Uninstall shell out to simctl. The driver does not call these
// today; they complete the Companion interface.
func (c *runnerCompanion) Install(ctx context.Context, appPath string) error {
command := exec.CommandContext(ctx, "xcrun", "simctl", "install", c.uniqueDeviceIdentifier, appPath)
if output, err := command.CombinedOutput(); err != nil {
return fmt.Errorf("simctl install: %v: %s", err, output)
}
return nil
}
func (c *runnerCompanion) Uninstall(ctx context.Context, bundleID string) error {
command := exec.CommandContext(ctx, "xcrun", "simctl", "uninstall", c.uniqueDeviceIdentifier, bundleID)
if output, err := command.CombinedOutput(); err != nil {
return fmt.Errorf("simctl uninstall: %v: %s", err, output)
}
return nil
}
func (c *runnerCompanion) InputText(ctx context.Context, text string) error {
return c.TypeText(ctx, text, true)
}
func (c *runnerCompanion) TypeText(ctx context.Context, text string, replace bool) error {
_, err := c.call(ctx, "typeText", map[string]any{"text": text, "replace": replace})
return err
}
func (c *runnerCompanion) EraseText(ctx context.Context, characterCount int) error {
_, err := c.call(ctx, "eraseText", map[string]any{"count": characterCount})
return err
}
func (c *runnerCompanion) PressKey(ctx context.Context, key string) error {
switch key {
case "enter", "return", "Enter", "Return":
_, err := c.call(ctx, "pressKey", map[string]any{"key": "return"})
return err
default:
return fmt.Errorf("runner companion cannot press key %q; only return is supported", key)
}
}
@@ -0,0 +1,556 @@
package transport
import (
"bufio"
"context"
"encoding/base64"
"encoding/json"
"errors"
"net"
"reflect"
"strconv"
"sync"
"testing"
"time"
)
var (
_ Companion = (*runnerCompanion)(nil)
_ TextEditor = (*runnerCompanion)(nil)
)
// scriptedReply maps a method name to the raw JSON object the fake server writes
// back as the "result" field. A method absent from the script gets an empty
// object result.
type scriptedReply map[string]string
// fakeServer is an in-process runner stand-in. It accepts a single connection,
// records every decoded request, and answers from a scripted table.
type fakeServer struct {
listener net.Listener
address string
mutex sync.Mutex
requests []runnerRequest
}
func startFakeServer(t *testing.T, script scriptedReply) *fakeServer {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
server := &fakeServer{listener: listener, address: listener.Addr().String()}
go server.serve(script)
t.Cleanup(func() { listener.Close() })
return server
}
func (s *fakeServer) serve(script scriptedReply) {
conn, err := s.listener.Accept()
if err != nil {
return
}
defer conn.Close()
reader := bufio.NewReader(conn)
for {
line, err := reader.ReadBytes('\n')
if err != nil {
return
}
var request runnerRequest
if err := json.Unmarshal(line, &request); err != nil {
return
}
s.mutex.Lock()
s.requests = append(s.requests, request)
s.mutex.Unlock()
result := script[request.Method]
if result == "" {
result = "{}"
}
response := `{"id":` + strconv.Itoa(request.ID) + `,"result":` + result + "}\n"
if _, err := conn.Write([]byte(response)); err != nil {
return
}
}
}
func (s *fakeServer) recorded() []runnerRequest {
s.mutex.Lock()
defer s.mutex.Unlock()
out := make([]runnerRequest, len(s.requests))
copy(out, s.requests)
return out
}
func dialFake(t *testing.T, server *fakeServer, bundleID string) Companion {
t.Helper()
companion, err := DialRunner(server.address, "UDID-1234", bundleID)
if err != nil {
t.Fatalf("DialRunner: %v", err)
}
t.Cleanup(func() { companion.Close() })
return companion
}
func TestSnapshotReMarshalsElements(t *testing.T) {
server := startFakeServer(t, scriptedReply{
"snapshot": `{"elements":[{"role":"button"},{"role":"text"}]}`,
})
companion := dialFake(t, server, "com.example.app")
got, err := companion.AccessibilityInfo(context.Background())
if err != nil {
t.Fatalf("AccessibilityInfo: %v", err)
}
want := `[{"role":"button"},{"role":"text"}]`
if got != want {
t.Fatalf("elements = %s, want %s", got, want)
}
requests := server.recorded()
if len(requests) != 1 {
t.Fatalf("recorded %d requests, want 1", len(requests))
}
if requests[0].Method != "snapshot" {
t.Fatalf("method = %s, want snapshot", requests[0].Method)
}
if requests[0].Params["bundleId"] != "com.example.app" {
t.Fatalf("bundleId = %v, want com.example.app", requests[0].Params["bundleId"])
}
}
func TestGestureEncodesDoubleTapStream(t *testing.T) {
server := startFakeServer(t, nil)
companion := dialFake(t, server, "com.example.app")
err := companion.SendHID(context.Background(),
TouchDown(10, 20),
TouchUp(10, 20),
Delay(60),
TouchDown(10, 20),
TouchUp(10, 20),
)
if err != nil {
t.Fatalf("SendHID: %v", err)
}
requests := server.recorded()
if len(requests) != 1 || requests[0].Method != "gesture" {
t.Fatalf("requests = %+v, want one gesture", requests)
}
events, ok := requests[0].Params["events"].([]any)
if !ok {
t.Fatalf("events not an array: %T", requests[0].Params["events"])
}
want := []map[string]any{
{"kind": "touchDown", "x": 10.0, "y": 20.0},
{"kind": "touchUp", "x": 10.0, "y": 20.0},
{"kind": "delay", "milliseconds": 60.0},
{"kind": "touchDown", "x": 10.0, "y": 20.0},
{"kind": "touchUp", "x": 10.0, "y": 20.0},
}
if len(events) != len(want) {
t.Fatalf("got %d events, want %d", len(events), len(want))
}
for index, event := range events {
if !reflect.DeepEqual(event, map[string]any(want[index])) {
t.Fatalf("event %d = %v, want %v", index, event, want[index])
}
}
}
func TestSendHIDRejectsKeyEvents(t *testing.T) {
server := startFakeServer(t, nil)
companion := dialFake(t, server, "com.example.app")
err := companion.SendHID(context.Background(), KeyDown(4), KeyUp(4))
if err == nil {
t.Fatal("expected error for key HID events")
}
if errors.Is(err, ErrCompanionUnavailable) {
t.Fatalf("key rejection should not be a transport error: %v", err)
}
if requests := server.recorded(); len(requests) != 0 {
t.Fatalf("expected nothing sent, got %+v", requests)
}
}
func TestScreenshotBase64RoundTrip(t *testing.T) {
original := []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a}
encoded := base64.StdEncoding.EncodeToString(original)
server := startFakeServer(t, scriptedReply{
"screenshot": `{"pngBase64":"` + encoded + `"}`,
})
companion := dialFake(t, server, "com.example.app")
data, format, err := companion.Screenshot(context.Background())
if err != nil {
t.Fatalf("Screenshot: %v", err)
}
if format != "png" {
t.Fatalf("format = %s, want png", format)
}
if !reflect.DeepEqual(data, original) {
t.Fatalf("data = %v, want %v", data, original)
}
}
func TestDescribeMapsScreenDescription(t *testing.T) {
server := startFakeServer(t, scriptedReply{
"describe": `{"widthPoints":390,"heightPoints":844,"scale":3.0}`,
})
companion := dialFake(t, server, "com.example.app")
description, err := companion.Describe(context.Background())
if err != nil {
t.Fatalf("Describe: %v", err)
}
want := ScreenDescription{WidthPoints: 390, HeightPoints: 844, Scale: 3.0}
if description != want {
t.Fatalf("description = %+v, want %+v", description, want)
}
}
func TestTextEditorMethods(t *testing.T) {
server := startFakeServer(t, nil)
companion := dialFake(t, server, "com.example.app")
editor := companion.(TextEditor)
if err := editor.InputText(context.Background(), "hello"); err != nil {
t.Fatalf("InputText: %v", err)
}
if err := editor.EraseText(context.Background(), 3); err != nil {
t.Fatalf("EraseText: %v", err)
}
if err := editor.PressKey(context.Background(), "Enter"); err != nil {
t.Fatalf("PressKey: %v", err)
}
requests := server.recorded()
if len(requests) != 3 {
t.Fatalf("recorded %d requests, want 3", len(requests))
}
if requests[0].Method != "typeText" || requests[0].Params["text"] != "hello" || requests[0].Params["replace"] != true {
t.Fatalf("typeText request = %+v", requests[0])
}
if requests[1].Method != "eraseText" || requests[1].Params["count"] != 3.0 {
t.Fatalf("eraseText request = %+v", requests[1])
}
if requests[2].Method != "pressKey" || requests[2].Params["key"] != "return" {
t.Fatalf("pressKey request = %+v", requests[2])
}
}
func TestPressKeyRejectsUnknownKey(t *testing.T) {
server := startFakeServer(t, nil)
companion := dialFake(t, server, "com.example.app")
editor := companion.(TextEditor)
err := editor.PressKey(context.Background(), "home")
if err == nil {
t.Fatal("expected error for unsupported key")
}
if errors.Is(err, ErrCompanionUnavailable) {
t.Fatalf("unsupported key should not be a transport error: %v", err)
}
if requests := server.recorded(); len(requests) != 0 {
t.Fatalf("expected nothing sent, got %+v", requests)
}
}
func TestLaunchTerminateMapping(t *testing.T) {
server := startFakeServer(t, nil)
companion := dialFake(t, server, "com.example.app")
if err := companion.Launch(context.Background(), "com.example.target", true); err != nil {
t.Fatalf("Launch: %v", err)
}
if err := companion.Terminate(context.Background(), "com.example.target"); err != nil {
t.Fatalf("Terminate: %v", err)
}
requests := server.recorded()
if len(requests) != 2 {
t.Fatalf("recorded %d requests, want 2", len(requests))
}
if requests[0].Method != "launch" ||
requests[0].Params["bundleId"] != "com.example.target" ||
requests[0].Params["foregroundIfRunning"] != true {
t.Fatalf("launch request = %+v", requests[0])
}
if requests[1].Method != "terminate" || requests[1].Params["bundleId"] != "com.example.target" {
t.Fatalf("terminate request = %+v", requests[1])
}
}
func TestListAppsStateMapping(t *testing.T) {
cases := []struct {
state string
want ProcessState
}{
{"foreground", ProcessStateRunning},
{"background", ProcessStateRunning},
{"notRunning", ProcessStateNotRunning},
{"unknown", ProcessStateUnknown},
}
for _, testCase := range cases {
server := startFakeServer(t, scriptedReply{
"appState": `{"state":"` + testCase.state + `"}`,
})
companion := dialFake(t, server, "com.example.app")
apps, err := companion.ListApps(context.Background())
if err != nil {
t.Fatalf("ListApps(%s): %v", testCase.state, err)
}
if len(apps) != 1 {
t.Fatalf("ListApps(%s) returned %d apps, want 1", testCase.state, len(apps))
}
app := apps[0]
if app.BundleID != "com.example.app" || app.InstallType != "user" {
t.Fatalf("app fields = %+v", app)
}
if app.ProcessState != testCase.want {
t.Fatalf("state %s -> %v, want %v", testCase.state, app.ProcessState, testCase.want)
}
requests := server.recorded()
if len(requests) != 1 || requests[0].Method != "appState" || requests[0].Params["bundleId"] != "com.example.app" {
t.Fatalf("appState request = %+v", requests)
}
}
}
func TestServerErrorIsNotSentinel(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() { listener.Close() })
go func() {
conn, err := listener.Accept()
if err != nil {
return
}
defer conn.Close()
reader := bufio.NewReader(conn)
line, err := reader.ReadBytes('\n')
if err != nil {
return
}
var request runnerRequest
json.Unmarshal(line, &request)
conn.Write([]byte(`{"id":` + strconv.Itoa(request.ID) + `,"error":"field not focused"}` + "\n"))
}()
companion, err := DialRunner(listener.Addr().String(), "UDID", "com.example.app")
if err != nil {
t.Fatalf("DialRunner: %v", err)
}
t.Cleanup(func() { companion.Close() })
_, err = companion.Describe(context.Background())
if err == nil {
t.Fatal("expected server error")
}
if errors.Is(err, ErrCompanionUnavailable) {
t.Fatalf("server error must not wrap the sentinel: %v", err)
}
}
func TestServerClosesMidCallIsSentinel(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() { listener.Close() })
go func() {
conn, err := listener.Accept()
if err != nil {
return
}
// Read the request, then drop the connection without replying.
bufio.NewReader(conn).ReadBytes('\n')
conn.Close()
}()
companion, err := DialRunner(listener.Addr().String(), "UDID", "com.example.app")
if err != nil {
t.Fatalf("DialRunner: %v", err)
}
t.Cleanup(func() { companion.Close() })
_, err = companion.Describe(context.Background())
if err == nil {
t.Fatal("expected error when server closes mid-call")
}
if !errors.Is(err, ErrCompanionUnavailable) {
t.Fatalf("dropped connection must wrap the sentinel: %v", err)
}
}
func TestContextCancellationUnblocksCall(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() { listener.Close() })
go func() {
conn, err := listener.Accept()
if err != nil {
return
}
// Accept and hold the connection open, never replying.
bufio.NewReader(conn).ReadBytes('\n')
<-make(chan struct{})
}()
companion, err := DialRunner(listener.Addr().String(), "UDID", "com.example.app")
if err != nil {
t.Fatalf("DialRunner: %v", err)
}
t.Cleanup(func() { companion.Close() })
ctx, cancel := context.WithCancel(context.Background())
time.AfterFunc(50*time.Millisecond, cancel)
done := make(chan error, 1)
go func() {
_, callErr := companion.Describe(ctx)
done <- callErr
}()
select {
case callErr := <-done:
if callErr == nil {
t.Fatal("expected cancellation error")
}
// A caller-imposed cancel is the caller's budget, not a connection
// loss: it must NOT wrap the sentinel, or a slow call would trigger a
// pointless child restart.
if !errors.Is(callErr, context.Canceled) {
t.Fatalf("cancellation error must carry the context error: %v", callErr)
}
if errors.Is(callErr, ErrCompanionUnavailable) {
t.Fatalf("cancellation error must not wrap the sentinel: %v", callErr)
}
case <-time.After(2 * time.Second):
t.Fatal("cancelled call did not return within 2s")
}
}
func TestInterruptedCallReconnectsOnNextCall(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() { listener.Close() })
// The server holds "describe" hostage and answers everything else, on
// every connection it accepts. A late reply to the interrupted request
// must never be misread by the following call.
accepted := make(chan net.Conn, 4)
go func() {
for {
conn, acceptErr := listener.Accept()
if acceptErr != nil {
return
}
accepted <- conn
go func(c net.Conn) {
reader := bufio.NewReader(c)
for {
line, readErr := reader.ReadBytes('\n')
if readErr != nil {
return
}
var request runnerRequest
if json.Unmarshal(line, &request) != nil {
return
}
if request.Method == "describe" {
continue
}
response := `{"id":` + strconv.Itoa(request.ID) + `,"result":{"ok":true}}` + "\n"
if _, writeErr := c.Write([]byte(response)); writeErr != nil {
return
}
}
}(conn)
}
}()
companion, err := DialRunner(listener.Addr().String(), "UDID", "com.example.app")
if err != nil {
t.Fatalf("DialRunner: %v", err)
}
t.Cleanup(func() { companion.Close() })
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
if _, err := companion.Describe(ctx); err == nil {
t.Fatal("expected the held call to time out")
}
// The next call must transparently reconnect and succeed.
if err := companion.Terminate(context.Background(), "com.example.app"); err != nil {
t.Fatalf("call after interrupt: %v", err)
}
if len(accepted) != 2 {
t.Fatalf("accepted %d connections, want 2 (reconnect)", len(accepted))
}
}
func TestResponseIDMismatchIsSentinel(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() { listener.Close() })
go func() {
conn, err := listener.Accept()
if err != nil {
return
}
defer conn.Close()
bufio.NewReader(conn).ReadBytes('\n')
// Reply with an id that cannot match any request.
conn.Write([]byte(`{"id":9999,"result":{}}` + "\n"))
}()
companion, err := DialRunner(listener.Addr().String(), "UDID", "com.example.app")
if err != nil {
t.Fatalf("DialRunner: %v", err)
}
t.Cleanup(func() { companion.Close() })
_, err = companion.Describe(context.Background())
if err == nil {
t.Fatal("expected id-mismatch error")
}
if !errors.Is(err, ErrCompanionUnavailable) {
t.Fatalf("id mismatch must wrap the sentinel: %v", err)
}
}
func TestTypeTextAppendsWithoutReplace(t *testing.T) {
server := startFakeServer(t, nil)
companion := dialFake(t, server, "com.example.app")
typer := companion.(TextTyper)
if err := typer.TypeText(context.Background(), "héllo 🌟", false); err != nil {
t.Fatalf("TypeText: %v", err)
}
requests := server.recorded()
if len(requests) != 1 {
t.Fatalf("recorded %d requests, want 1", len(requests))
}
request := requests[0]
if request.Method != "typeText" || request.Params["text"] != "héllo 🌟" || request.Params["replace"] != false {
t.Fatalf("typeText request = %+v", request)
}
}
@@ -0,0 +1,116 @@
// Package transport is the only layer that touches the generated companion
// gRPC stubs. Everything above it speaks the brand-free Companion interface,
// so the companion binary stays swappable behind this boundary.
package transport
import (
"context"
"errors"
pb "github.com/priyanshujain/sanderling/internal/driver/ioscompanion/companionpb"
)
// Companion drives a single booted iOS simulator through the companion.
// Callers own per-call deadlines by passing a context.
type Companion interface {
// AccessibilityInfo returns the describe-all accessibility tree as the
// raw flat-format JSON string the companion emits.
AccessibilityInfo(ctx context.Context) (string, error)
// Describe reports the target's screen dimensions in points, along with
// the pixel scale when the companion supplies one.
Describe(ctx context.Context) (ScreenDescription, error)
// SendHID opens the HID stream, sends every event in order, then closes.
SendHID(ctx context.Context, events ...HIDEvent) error
// Screenshot captures the current screen, returning the encoded image
// bytes and the image format string the companion reports.
Screenshot(ctx context.Context) (imageData []byte, imageFormat string, err error)
// Launch brings the app to the foreground, starting it if needed.
Launch(ctx context.Context, bundleID string, foregroundIfRunning bool) error
// Terminate stops the running app with the given bundle identifier.
Terminate(ctx context.Context, bundleID string) error
// ListApps reports every installed app and its current process state.
ListApps(ctx context.Context) ([]InstalledApp, error)
// Install installs a .app bundle directory by streaming it to the
// companion.
Install(ctx context.Context, appPath string) error
// Uninstall removes the app with the given bundle identifier.
Uninstall(ctx context.Context, bundleID string) error
// Close releases the underlying connection.
Close() error
}
// TextEditor is an optional companion capability: the transport edits text
// natively on the device instead of the driver composing keyboard HID streams.
// The driver routes text input through it when the companion implements it.
type TextEditor interface {
// InputText replaces the focused field's content with text.
InputText(ctx context.Context, text string) error
// EraseText deletes characterCount characters from the focused field.
EraseText(ctx context.Context, characterCount int) error
// PressKey presses the named logical key (currently only return/enter).
PressKey(ctx context.Context, key string) error
}
// TextTyper is an optional companion capability: the transport types text
// natively into whatever holds keyboard focus. Unlike TextEditor it exposes
// the replace flag, so a caller can clear the field through another channel
// and append with replace false.
type TextTyper interface {
TypeText(ctx context.Context, text string, replace bool) error
}
// ErrCompanionUnavailable marks a connection-level failure that a companion
// restart can recover from. Transports wrap dropped-connection errors with it.
var ErrCompanionUnavailable = errors.New("companion connection unavailable")
// ScreenDescription carries the target screen geometry. Width and Height are
// in points (the coordinate space HID events and the accessibility frames use).
// Scale is the pixel-per-point density, or 0 when the companion did not report
// one.
type ScreenDescription struct {
WidthPoints int
HeightPoints int
Scale float64
}
// ProcessState mirrors the companion's notion of whether an app is running.
type ProcessState int
const (
ProcessStateUnknown ProcessState = iota
ProcessStateNotRunning
ProcessStateRunning
)
// InstalledApp describes one installed app. ProcessState and ProcessIdentifier
// together answer whether the app is currently running and in the foreground.
type InstalledApp struct {
BundleID string
Name string
InstallType string
ProcessState ProcessState
Debuggable bool
ProcessIdentifier uint64
}
func processStateFromProto(s pb.InstalledAppInfo_AppProcessState) ProcessState {
switch s {
case pb.InstalledAppInfo_RUNNING:
return ProcessStateRunning
case pb.InstalledAppInfo_NOT_RUNNING:
return ProcessStateNotRunning
default:
return ProcessStateUnknown
}
}