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

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