mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
iOS simulator driver: Go-native companion-backed backend (#62)
* perf(ios): use prebuilt XCTest runner to cut startup * chore(ioscompanion): add companion asset prepare script * feat(ioscompanion): embed and extract simulator companion bundle * test(ioscompanion): cover companion stub and embedded extraction * docs: add third party notices for vendored companion * chore: ignore vendored companion bundle artifact * build(proto): pin simulator companion proto v1.1.8 * build(proto): add dedicated buf module and gen template for pinned proto * build(proto): exclude pinned companion proto from root buf workspace * feat(ioscompanion): commit generated companion gRPC stubs * feat(ioscompanion): map flat companion describe dump to TreeNode JSON * test(ioscompanion): add hierarchy-map golden and unit tests * feat(ioscompanion): port screen-settle stability polling to Go * test(ioscompanion): cover settle transitional, hash, streak, and cap rules * feat(ioscompanion): add USB HID keymap module * test(ioscompanion): cover keymap branches and paste-chord constants * build: embed companion assets via withcompanion tag * feat(ioscompanion): add transport companion interface * feat(ioscompanion): add HID event wrapper and builders * feat(ioscompanion): wire gRPC companion client and Dial * test(ioscompanion): cover HID builders and unit conversions * test(ioscompanion): cover Dial, process-state mapping, and install archive * test(ioscompanion): add gated simulator integration smoke test * feat(ioscompanion): text input and gesture HID composition with pasteboard fallback * test(ioscompanion): cover input composers, paste dialog loop, and pure helpers * feat(ioscompanion): add Describe to companion transport * feat(ioscompanion): implement DeviceDriver with companion supervision * test(ioscompanion): unit tests with fake companion transport * test(ioscompanion): gated companion smoke test * feat(ios): add ResolveTarget for simulator vs physical-device routing * feat(testrun): route iOS simulators through the native companion driver * refactor(testrun): defer the java preflight check to the physical-device path * feat(cli): add --ios-app-path flag * feat(doctor): split iOS checks into simulator and physical-device paths * test(folio): add gate-analyzer fixtures for G1-G5 * feat(folio): add iOS conformance gate script * chore(folio): wire gates recipe, app path, and ignore gate output * style: gofmt struct alignment drift * fix(doctor): probe simctl via xcrun instead of PATH lookup * fix(ioscompanion): spawn companion under driver-lifetime context * test(ioscompanion): prove companion child outlives startup context * fix(ioscompanion): chunk install payload under companion message cap * test(ioscompanion): cover install payload chunking * fix(ioscompanion): reinstall via simctl and sanitize companion env * fix(ioscompanion): wait out unresolved accessibility values after launch * perf(ioscompanion): paste long text for atomic landing * test(ioscompanion): cover paste threshold, retry flow, and sentinel detection * fix(ioscompanion): treat unresolved bridge values as transitional, never as content * fix(ioscompanion): accept masked secure-field values as paste landing * test(ioscompanion): cover sentinel mapping and masked-field landing * fix(ioscompanion): atomic erase and single-send paste to prevent doubling * test(ioscompanion): cover atomic erase, single chord, unverifiable field * fix(ioscompanion): verify paste on a time budget that outlasts the bridge blackout * test(ioscompanion): cover bridge-blackout paste verification * fix(ioscompanion): drop unresolved-value settle gate that never let empty-field screens settle * refactor(ioscompanion): name the empty-editable-field sentinel for what it is * perf(ioscompanion): tighten settle streak for the fast companion transport * feat(ioscompanion): pre-grant pasteboard access so unicode input skips the OS prompt * refactor(ioscompanion): drop paste warm-up now that the grant suppresses the prompt * test(ioscompanion): cover pasteboard grant on launch, drop warm-up tests * fix(ioscompanion): retry describe past transient collapsed accessibility dumps * test(ioscompanion): cover collapsed-dump detection * perf(ioscompanion): split raw and retrying describe so settle does not double-wait collapses * perf(ioscompanion): tighten settle now that collapses are handled separately * fix(ioscompanion): replace field content on input so blackout-skipped erase cannot accumulate text * test(ioscompanion): cover replace-on-input and TextReplacer capability * refactor(ioscompanion): neutralize HID events behind the transport seam * feat(companion): add simulator runner project skeleton * feat(companion): serve accessibility snapshots over the wire protocol * feat(companion): synthesize timestamped touch gestures * feat(companion): type text with replace semantics * feat(companion): serve the wire protocol from a parked runner * feat(ioscompanion): add TextEditor capability and unavailable sentinel to the transport seam * feat(ioscompanion): route text input through a text-editing companion when available * fix(companion): bind listener by port and source screen size from snapshot * feat(ioscompanion): add runner companion JSON transport * test(ioscompanion): cover runner transport protocol mapping * fix(companion): synthesize gestures synchronously to avoid the async completion crash * fix(companion): type on the main thread and recover from focus assertions * fix(companion): keep serving after an automation failure * refactor(companion): tidy snapshot serialization * fix(companion): honor sequential tap gaps and survive synthesis exceptions * feat(ioscompanion): expose native typing with an explicit replace flag * chore(companion): add runner asset prepare script * feat(ioscompanion): embed and extract the runner test bundle * test(ioscompanion): cover runner asset extraction * build(ioscompanion): commit runner asset archive * feat(ioscompanion): pair the legacy companion with the in-simulator runner * test(ioscompanion): cover hybrid routing, paste-grant skip, and port binding * fix(ioscompanion): reconnect after interrupted runner calls instead of restarting * fix(ioscompanion): route hybrid lifecycle through the runner and harden restarts * feat(companion): launch and terminate apps through the automation session * build(ioscompanion): refresh runner asset with session lifecycle * fix(ioscompanion): classify connection deadline expiry as caller budget * fix(companion): capture snapshots on the main thread inside the catch bridge * build(ioscompanion): refresh runner asset with main-thread snapshots * perf(ioscompanion): count read spans toward settle and capture snapshots concurrently * feat(ioscompanion): make the hybrid simulator companion the default * test(folio): cover runner-session orphans in the gate harness * test(ioscompanion): pin the child-lifetime test to the legacy path * fix(ioscompanion): keep mappable text on one HID stream and verify unicode clears * fix(ioscompanion): pause the clear chord so selection applies before the delete * fix(companion): prune the keyboard subtree from snapshots * build(ioscompanion): refresh runner asset without keyboard elements * fix(ioscompanion): capture the screenshot transport before a recovery can reassign it * fix(companion): pin the runner listener to loopback * fix(companion): size the replace delete prefix to cover any focused field * build(ioscompanion): refresh runner asset with loopback bind and replace fix * fix(cli): cancel the run context on SIGINT so spawned children are reaped * fix(testrun): point the device java preflight hint at the ios-device doctor * fix(folio): word-bound the G2 ERROR scan and drop the dead objc allowlist glob * test(ioscompanion): cover stopProcess, restart, and failed bring-up supervision * chore: add test-companion target for the withcompanion-tagged suite * chore(ioscompanion): stop tracking the runner archive build artifact * build: produce the runner archive from source like the companion bundle * refactor(conformance): move the gate harness out of examples/folio * chore(folio): drop the gate harness wiring from the example app
This commit is contained in:
97 files changed
+22104
-83
No files matched your search
@@ -0,0 +1,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
@@ -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
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(')')
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user