mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 11:07:10 +00:00
Merge remote-tracking branch 'origin/master' into update-docs
This commit is contained in:
commit
8307e3cb8a
118 files changed
+24388
-448
No files matched your search
+13
@@ -32,6 +32,14 @@ node_modules/
|
||||
# `go build -tags withsidecar`. Never commit: it's ~130 MB.
|
||||
internal/sidecarassets/assets/sidecar-all.jar
|
||||
|
||||
# Simulator companion bundle. Build artifact produced by prepare.sh before
|
||||
# `go build -tags withcompanion`. Never commit: it's ~16 MB.
|
||||
internal/driver/ioscompanion/companionassets/assets/companion-*.tar.gz
|
||||
|
||||
# In-simulator runner bundle. Build artifact produced by companion/prepare.sh
|
||||
# before `go build -tags withcompanion`. Never commit: it's ~5 MB.
|
||||
internal/driver/ioscompanion/runnerassets/assets/runner-*.tar.gz
|
||||
|
||||
# spec-api compiled output
|
||||
pkg/spec/dist/
|
||||
|
||||
@@ -48,6 +56,10 @@ replay-ui/node_modules/
|
||||
replay-ui/dist/
|
||||
replay-ui/.vite/
|
||||
|
||||
# Simulator runner generated project and build output (xcodegen artifacts)
|
||||
companion/CompanionRunner.xcodeproj/
|
||||
companion/build/
|
||||
|
||||
# folio-web frontend
|
||||
examples/folio-web/node_modules/
|
||||
examples/folio-web/dist/
|
||||
@@ -61,3 +73,4 @@ examples/folio-web/.vite/
|
||||
/research/
|
||||
|
||||
/talk/
|
||||
keys/*
|
||||
@@ -8,7 +8,12 @@ BUF := buf
|
||||
GO_PACKAGES := ./...
|
||||
SIDECAR_JAR := sidecar/build/libs/sidecar-all.jar
|
||||
SIDECAR_EMBED := internal/sidecarassets/assets/sidecar-all.jar
|
||||
SIDECAR_SRC := $(shell find sidecar/src -type f \( -name '*.kt' -o -name '*.kts' \) 2>/dev/null) build.gradle.kts settings.gradle.kts
|
||||
COMPANION_EMBED := internal/driver/ioscompanion/companionassets/assets/companion-1.1.8.tar.gz
|
||||
COMPANION_PREPARE := internal/driver/ioscompanion/companionassets/prepare.sh
|
||||
RUNNER_EMBED := internal/driver/ioscompanion/runnerassets/assets/runner-1.0.0.tar.gz
|
||||
RUNNER_PREPARE := companion/prepare.sh
|
||||
RUNNER_SRC := $(shell find companion/Sources -type f -name '*.swift' 2>/dev/null) companion/project.yml
|
||||
SIDECAR_SRC := $(shell find sidecar/src -type f \( -name '*.kt' -o -name '*.kts' \) 2>/dev/null) sidecar/build.gradle.kts build.gradle.kts settings.gradle.kts
|
||||
SANDERLING_BIN := bin/sanderling
|
||||
|
||||
DOCS_SRC := $(shell find docs -type f -name '*.md' -not -path 'docs/_*')
|
||||
@@ -22,7 +27,7 @@ DOCS_TEMPLATE := docs/_template/page.html
|
||||
REPLAY_DIST := internal/replay/dist
|
||||
WEB_DIST := replay-ui/dist
|
||||
|
||||
.PHONY: bootstrap proto sidecar sanderling install test test-go test-browser test-kotlin test-spec-api web-test web-typecheck web-build web-dev replay-dev docs clean release-cli release-npm-dry
|
||||
.PHONY: bootstrap proto sidecar sanderling install test test-go test-browser test-companion test-kotlin test-spec-api web-test web-typecheck web-build web-dev replay-dev docs clean release-cli release-npm-dry
|
||||
|
||||
bootstrap:
|
||||
$(GO) mod download
|
||||
@@ -37,14 +42,14 @@ sidecar: $(SIDECAR_JAR)
|
||||
|
||||
sanderling: $(SANDERLING_BIN)
|
||||
|
||||
$(SANDERLING_BIN): $(SIDECAR_EMBED) web-build
|
||||
$(SANDERLING_BIN): $(SIDECAR_EMBED) $(COMPANION_EMBED) $(RUNNER_EMBED) web-build
|
||||
mkdir -p bin
|
||||
$(GO) build -tags withsidecar -o $(SANDERLING_BIN) ./cmd/sanderling
|
||||
$(GO) build -tags "withsidecar withcompanion" -o $(SANDERLING_BIN) ./cmd/sanderling
|
||||
|
||||
# Installs `sanderling` into $GOBIN (or $GOPATH/bin) so it's directly on PATH for
|
||||
# anyone with a standard Go toolchain setup.
|
||||
install: $(SIDECAR_EMBED) web-build
|
||||
$(GO) install -tags withsidecar ./cmd/sanderling
|
||||
install: $(SIDECAR_EMBED) $(COMPANION_EMBED) $(RUNNER_EMBED) web-build
|
||||
$(GO) install -tags "withsidecar withcompanion" ./cmd/sanderling
|
||||
@dest="$$($(GO) env GOBIN)"; [ -n "$$dest" ] || dest="$$($(GO) env GOPATH)/bin"; echo "installed sanderling to $$dest"
|
||||
|
||||
web-build:
|
||||
@@ -69,6 +74,12 @@ $(SIDECAR_EMBED): $(SIDECAR_JAR)
|
||||
mkdir -p $(dir $@)
|
||||
cp $< $@
|
||||
|
||||
$(COMPANION_EMBED): $(COMPANION_PREPARE)
|
||||
$(COMPANION_PREPARE)
|
||||
|
||||
$(RUNNER_EMBED): $(RUNNER_SRC) $(RUNNER_PREPARE)
|
||||
$(RUNNER_PREPARE)
|
||||
|
||||
test: test-go test-spec-api web-typecheck web-test
|
||||
|
||||
test-go:
|
||||
@@ -82,6 +93,12 @@ web-test:
|
||||
test-browser:
|
||||
$(GO) test -tags browser ./test/browser/... ./internal/driver/chrome/...
|
||||
|
||||
# Runs the withcompanion-tagged tests (asset embedding, extraction, checksum
|
||||
# reuse) against the real companion and runner bundles. Kept out of `test`
|
||||
# because preparing the companion bundle needs the darwin toolchain.
|
||||
test-companion: $(COMPANION_EMBED) $(RUNNER_EMBED)
|
||||
$(GO) test -tags withcompanion ./internal/driver/ioscompanion/...
|
||||
|
||||
test-kotlin:
|
||||
ANDROID_HOME=$(ANDROID_HOME) $(GRADLE) :sidecar:test
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# Third Party Notices
|
||||
|
||||
This project distributes third party software. The components below are
|
||||
included under their respective licenses.
|
||||
|
||||
## idb-companion
|
||||
|
||||
- Project: idb (https://github.com/facebook/idb)
|
||||
- Version: 1.1.8
|
||||
- Copyright: Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
- License: MIT
|
||||
|
||||
The simulator companion is vendored as a prebuilt binary artifact (the
|
||||
`idb_companion` executable together with its runtime frameworks). It is
|
||||
embedded into the sanderling binary at build time and extracted to a local
|
||||
directory at runtime.
|
||||
|
||||
### MIT License
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,6 +1,13 @@
|
||||
version: v2
|
||||
# The pinned simulator companion proto lives under proto/idbcompanion and is
|
||||
# deliberately excluded here. It is its own standalone buf module (see
|
||||
# proto/idbcompanion/buf.yaml) so the STANDARD lint rules below never apply to
|
||||
# it and a normal `make proto` never generates from it. Its stubs are committed
|
||||
# and regenerated only via proto/idbcompanion/buf.gen.yaml.
|
||||
modules:
|
||||
- path: proto
|
||||
excludes:
|
||||
- proto/idbcompanion
|
||||
lint:
|
||||
use:
|
||||
- STANDARD
|
||||
|
||||
@@ -14,6 +14,8 @@ import (
|
||||
|
||||
"github.com/chromedp/chromedp"
|
||||
|
||||
"github.com/priyanshujain/sanderling/internal/driver/ioscompanion"
|
||||
"github.com/priyanshujain/sanderling/internal/ios"
|
||||
"github.com/priyanshujain/sanderling/internal/sidecarassets"
|
||||
)
|
||||
|
||||
@@ -33,6 +35,8 @@ func doctorChecksFor(platform string) []doctorCheck {
|
||||
return androidChecks()
|
||||
case "ios":
|
||||
return iosChecks()
|
||||
case "ios-device":
|
||||
return append(iosChecks(), iosDeviceChecks()...)
|
||||
case "all":
|
||||
return allChecks()
|
||||
default:
|
||||
@@ -55,19 +59,37 @@ func androidChecks() []doctorCheck {
|
||||
}
|
||||
}
|
||||
|
||||
// iosChecks covers the simulator path, which the native companion drives with
|
||||
// no JVM. A simulator host with no Java still passes. Physical-device runs
|
||||
// additionally need devicectl, the usbmuxd socket, a connected device, and
|
||||
// signing credentials, covered by iosDeviceChecks and surfaced through the
|
||||
// "all" union.
|
||||
func iosChecks() []doctorCheck {
|
||||
return []doctorCheck{
|
||||
{Name: "xcrun on PATH", Run: checkExecutableOnPath("xcrun")},
|
||||
{Name: "simctl on PATH", Run: checkExecutableOnPath("simctl")},
|
||||
{Name: "java 17+ on PATH", Run: checkJavaVersion},
|
||||
{Name: "sidecar JAR is real (not placeholder)", Run: checkSidecarJAR},
|
||||
{Name: "xcrun on PATH (ios simulator)", Run: checkExecutableOnPath("xcrun")},
|
||||
{Name: "simctl available (ios simulator)", Run: checkSimctl},
|
||||
}
|
||||
}
|
||||
|
||||
// iosDeviceChecks covers the prerequisites a physical iOS device needs: the
|
||||
// runner is built and driven over a native usbmux tunnel, so devicectl installs
|
||||
// the app, the macOS usbmuxd socket carries the tunnel, a device must be
|
||||
// connected and paired, and App Store Connect signing credentials must be
|
||||
// present for the no-UI build. Everything here is part of macOS + Xcode; nothing
|
||||
// is installed.
|
||||
func iosDeviceChecks() []doctorCheck {
|
||||
return []doctorCheck{
|
||||
{Name: "devicectl available (ios physical device)", Run: checkDevicectl},
|
||||
{Name: "usbmuxd socket present (ios physical device)", Run: checkUsbmuxd},
|
||||
{Name: "an iOS device is connected and paired", Run: checkDeviceConnected},
|
||||
{Name: "App Store Connect signing credentials present", Run: checkDeviceSigning},
|
||||
}
|
||||
}
|
||||
|
||||
func allChecks() []doctorCheck {
|
||||
seen := map[string]bool{}
|
||||
var combined []doctorCheck
|
||||
for _, group := range [][]doctorCheck{webChecks(), androidChecks(), iosChecks()} {
|
||||
for _, group := range [][]doctorCheck{webChecks(), androidChecks(), iosChecks(), iosDeviceChecks()} {
|
||||
for _, c := range group {
|
||||
if seen[c.Name] {
|
||||
continue
|
||||
@@ -98,6 +120,57 @@ func checkChromiumLaunch(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkSimctl exercises `xcrun simctl help`: simctl is an xcrun subcommand,
|
||||
// not a standalone binary, so a PATH lookup can never find it.
|
||||
func checkSimctl(ctx context.Context) error {
|
||||
if err := exec.CommandContext(ctx, "xcrun", "simctl", "help").Run(); err != nil {
|
||||
return fmt.Errorf("xcrun simctl help: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Device-check seams: package-level so the doctor's device checks run against
|
||||
// canned results instead of a real device.
|
||||
var (
|
||||
doctorConnectedDevices = ios.ConnectedDevices
|
||||
doctorVerifySigning = ioscompanion.VerifyDeviceSigning
|
||||
doctorVerifyUsbmuxd = ioscompanion.VerifyUsbmuxdSocket
|
||||
)
|
||||
|
||||
// checkDevicectl exercises `xcrun devicectl --version`: devicectl is an xcrun
|
||||
// subcommand, so a PATH lookup cannot find it.
|
||||
func checkDevicectl(ctx context.Context) error {
|
||||
if err := exec.CommandContext(ctx, "xcrun", "devicectl", "--version").Run(); err != nil {
|
||||
return fmt.Errorf("xcrun devicectl --version: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkUsbmuxd confirms the macOS usbmuxd socket is present: the native device
|
||||
// tunnel speaks to it directly instead of shelling out to a third-party client.
|
||||
func checkUsbmuxd(_ context.Context) error {
|
||||
return doctorVerifyUsbmuxd()
|
||||
}
|
||||
|
||||
// checkDeviceConnected confirms at least one physical iOS device is connected
|
||||
// and paired, the prerequisite for the tunnel and the install.
|
||||
func checkDeviceConnected(ctx context.Context) error {
|
||||
devices, err := doctorConnectedDevices(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(devices) == 0 {
|
||||
return fmt.Errorf("no connected iOS device; connect and pair an iPhone")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkDeviceSigning confirms the App Store Connect signing environment is
|
||||
// complete and the key file exists, so the no-UI device build can sign.
|
||||
func checkDeviceSigning(_ context.Context) error {
|
||||
return doctorVerifySigning()
|
||||
}
|
||||
|
||||
func checkSidecarJAR(_ context.Context) error {
|
||||
if sidecarassets.IsPlaceholder() {
|
||||
return fmt.Errorf("placeholder JAR embedded; run `make sidecar && make sanderling` to embed the real fat JAR")
|
||||
@@ -116,15 +189,15 @@ func parseDoctorArgs(args []string, stderr io.Writer) (doctorOptions, error) {
|
||||
flagSet := flag.NewFlagSet("doctor", flag.ContinueOnError)
|
||||
flagSet.SetOutput(stderr)
|
||||
var options doctorOptions
|
||||
flagSet.StringVar(&options.platform, "platform", "all", "target platform: web, android, ios, all")
|
||||
flagSet.StringVar(&options.platform, "platform", "all", "target platform: web, android, ios, ios-device, all")
|
||||
if err := flagSet.Parse(args); err != nil {
|
||||
return doctorOptions{}, err
|
||||
}
|
||||
switch options.platform {
|
||||
case "web", "android", "ios", "all":
|
||||
case "web", "android", "ios", "ios-device", "all":
|
||||
return options, nil
|
||||
default:
|
||||
return doctorOptions{}, fmt.Errorf("unsupported platform: %q (web, android, ios, all)", options.platform)
|
||||
return doctorOptions{}, fmt.Errorf("unsupported platform: %q (web, android, ios, ios-device, all)", options.platform)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/priyanshujain/sanderling/internal/ios"
|
||||
)
|
||||
|
||||
func TestRunDoctorChecks_AllPass(t *testing.T) {
|
||||
@@ -127,13 +129,76 @@ func TestDoctorChecksFor_All_IsUnion(t *testing.T) {
|
||||
for _, c := range all {
|
||||
names[c.Name]++
|
||||
}
|
||||
for _, name := range []string{"adb on PATH", "xcrun on PATH", "headless chromium can launch"} {
|
||||
for _, name := range []string{"adb on PATH", "xcrun on PATH (ios simulator)", "headless chromium can launch"} {
|
||||
if names[name] != 1 {
|
||||
t.Errorf("expected %q in 'all' exactly once, got %d", name, names[name])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorChecksFor_iOSSimulator_OmitsJava(t *testing.T) {
|
||||
for _, c := range doctorChecksFor("ios") {
|
||||
if strings.Contains(c.Name, "java") || strings.Contains(c.Name, "sidecar") {
|
||||
t.Errorf("ios simulator checks must omit %q; simulator runs need no JVM", c.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorChecksFor_iOSDevice_CoversDevicePrereqs(t *testing.T) {
|
||||
checks := doctorChecksFor("ios-device")
|
||||
for _, c := range checks {
|
||||
if strings.Contains(c.Name, "java") || strings.Contains(c.Name, "sidecar") {
|
||||
t.Errorf("device checks must not include the retired %q", c.Name)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"devicectl", "usbmuxd", "connected and paired", "signing credentials"} {
|
||||
found := false
|
||||
for _, c := range checks {
|
||||
if strings.Contains(c.Name, want) {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("ios-device checks missing %q: %+v", want, checks)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckDeviceConnected(t *testing.T) {
|
||||
original := doctorConnectedDevices
|
||||
t.Cleanup(func() { doctorConnectedDevices = original })
|
||||
|
||||
doctorConnectedDevices = func(context.Context) ([]ios.Device, error) {
|
||||
return []ios.Device{{Name: "iPhone"}}, nil
|
||||
}
|
||||
if err := checkDeviceConnected(context.Background()); err != nil {
|
||||
t.Fatalf("a connected device must pass: %v", err)
|
||||
}
|
||||
|
||||
doctorConnectedDevices = func(context.Context) ([]ios.Device, error) { return nil, nil }
|
||||
if err := checkDeviceConnected(context.Background()); err == nil {
|
||||
t.Fatal("no device must fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckDeviceSigning_SurfacesSeamResult(t *testing.T) {
|
||||
// checkDeviceSigning is a passthrough to the driver's credential check; the
|
||||
// credential logic itself is covered by TestReadSigningCredentials* in the
|
||||
// ioscompanion package. Here we only confirm the wiring through the seam.
|
||||
original := doctorVerifySigning
|
||||
t.Cleanup(func() { doctorVerifySigning = original })
|
||||
|
||||
doctorVerifySigning = func() error { return nil }
|
||||
if err := checkDeviceSigning(context.Background()); err != nil {
|
||||
t.Fatalf("a passing signing check must surface nil: %v", err)
|
||||
}
|
||||
|
||||
doctorVerifySigning = func() error { return errors.New("missing credentials") }
|
||||
if err := checkDeviceSigning(context.Background()); err == nil {
|
||||
t.Fatal("a failing signing check must surface the error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorChecksFor_UnknownPlatform(t *testing.T) {
|
||||
if got := doctorChecksFor("fuchsia"); got != nil {
|
||||
t.Errorf("expected nil for unknown platform, got %+v", got)
|
||||
|
||||
+10
-2
@@ -8,6 +8,8 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -21,6 +23,7 @@ type testOptions struct {
|
||||
platform string
|
||||
avd string
|
||||
iosDevice string
|
||||
iosAppPath string
|
||||
duration time.Duration
|
||||
seed int64
|
||||
output string
|
||||
@@ -49,7 +52,8 @@ func parseTestArgs(args []string, stderr io.Writer) (testOptions, error) {
|
||||
flagSet.StringVar(&options.bundleID, "bundle-id", "", "target app bundle ID (required)")
|
||||
flagSet.StringVar(&options.platform, "platform", "android", "target platform: android, ios, web")
|
||||
flagSet.StringVar(&options.avd, "avd", "", "Android AVD name to boot if no device is connected")
|
||||
flagSet.StringVar(&options.iosDevice, "ios-device", "", "iOS simulator name or UDID to boot if none is running")
|
||||
flagSet.StringVar(&options.iosDevice, "ios-device", "", "iOS target: a simulator name/UDID to boot, or a connected device's name, UDID, or CoreDevice id")
|
||||
flagSet.StringVar(&options.iosAppPath, "ios-app-path", "", "path to the .app bundle for iOS clear-state reinstall (simulator: simctl; device: devicectl)")
|
||||
flagSet.DurationVar(&options.duration, "duration", 5*time.Minute, "total test duration")
|
||||
flagSet.Int64Var(&options.seed, "seed", 0, "RNG seed (0 = random)")
|
||||
flagSet.StringVar(&options.output, "output", "./runs", "output directory for traces")
|
||||
@@ -72,7 +76,11 @@ func parseTestArgs(args []string, stderr io.Writer) (testOptions, error) {
|
||||
}
|
||||
|
||||
func runTest(options testOptions, stdout io.Writer) error {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
// A signal-aware root context: on Ctrl-C the cancellation propagates into
|
||||
// the drivers' process contexts, so spawned children (the iOS companion,
|
||||
// the xcodebuild runner session) get their SIGTERM instead of outliving
|
||||
// the run as orphans.
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
return runTestPipeline(ctx, options, stdout)
|
||||
}
|
||||
|
||||
@@ -181,6 +181,31 @@ func TestParseTestArgs_IosDeviceFlag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTestArgs_IosAppPathFlag(t *testing.T) {
|
||||
options, err := parseTestArgs([]string{
|
||||
"--spec", "s.ts",
|
||||
"--bundle-id", "com.example.app",
|
||||
"--platform", "ios",
|
||||
"--ios-app-path", "/tmp/build/iosApp.app",
|
||||
}, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if options.iosAppPath != "/tmp/build/iosApp.app" {
|
||||
t.Errorf("expected iosAppPath=/tmp/build/iosApp.app, got %q", options.iosAppPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTestArgs_IosAppPathOptional(t *testing.T) {
|
||||
options, err := parseTestArgs([]string{"--spec", "s.ts", "--bundle-id", "com.example.app"}, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if options.iosAppPath != "" {
|
||||
t.Errorf("iosAppPath default: got %q, want empty", options.iosAppPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_TestSubcommand_PipelineErrors(t *testing.T) {
|
||||
// Web skips host-dependent device boot and bundles first, so a missing spec
|
||||
// deterministically surfaces a bundle-resolution error. This proves the
|
||||
|
||||
@@ -14,6 +14,7 @@ func runTestPipeline(ctx context.Context, options testOptions, stdout io.Writer)
|
||||
Platform: options.platform,
|
||||
AVD: options.avd,
|
||||
IosDevice: options.iosDevice,
|
||||
IosAppPath: options.iosAppPath,
|
||||
Duration: options.duration,
|
||||
Seed: options.seed,
|
||||
Output: options.output,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import XCTest
|
||||
|
||||
// Launches and terminates apps through the automation session. Lifecycle
|
||||
// performed outside the session leaves its cached app proxies bound to dead
|
||||
// processes, after which snapshots hang and typing asserts.
|
||||
enum AppLifecycle {
|
||||
enum LifecycleError: Error {
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
static func launch(bundleIdentifier: String, foregroundIfRunning: Bool) throws {
|
||||
try onMainCatching {
|
||||
let application = XCUIApplication(bundleIdentifier: bundleIdentifier)
|
||||
if foregroundIfRunning {
|
||||
// activate starts the app when needed and foregrounds it when
|
||||
// already running, without a forced relaunch.
|
||||
application.activate()
|
||||
} else {
|
||||
application.launch()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func terminate(bundleIdentifier: String) throws {
|
||||
try onMainCatching {
|
||||
let application = XCUIApplication(bundleIdentifier: bundleIdentifier)
|
||||
if application.state == .notRunning {
|
||||
return
|
||||
}
|
||||
application.terminate()
|
||||
}
|
||||
}
|
||||
|
||||
// state reports the app's run state in the vocabulary the Go transport maps
|
||||
// to a process state: foreground, background, or notRunning. On the
|
||||
// simulator the hybrid never calls it; on device it backs ForegroundApp.
|
||||
static func state(bundleIdentifier: String) -> String {
|
||||
var result = "notRunning"
|
||||
let collect = {
|
||||
switch XCUIApplication(bundleIdentifier: bundleIdentifier).state {
|
||||
case .runningForeground:
|
||||
result = "foreground"
|
||||
case .runningBackground, .runningBackgroundSuspended:
|
||||
result = "background"
|
||||
default:
|
||||
result = "notRunning"
|
||||
}
|
||||
}
|
||||
if Thread.isMainThread {
|
||||
collect()
|
||||
} else {
|
||||
DispatchQueue.main.sync(execute: collect)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// onMainCatching runs automation work on the main thread and converts a
|
||||
// framework assertion into a thrown error so the server survives it.
|
||||
private static func onMainCatching(_ work: @escaping () -> Void) throws {
|
||||
var caughtError: NSError?
|
||||
var completed = false
|
||||
let run = {
|
||||
completed = CompanionRunCatching(work, &caughtError)
|
||||
}
|
||||
if Thread.isMainThread {
|
||||
run()
|
||||
} else {
|
||||
DispatchQueue.main.sync(execute: run)
|
||||
}
|
||||
if !completed {
|
||||
throw LifecycleError.failed(caughtError?.localizedDescription ?? "unknown")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import XCTest
|
||||
|
||||
// Maps XCUIElement.ElementType to the capitalized conventional type names the
|
||||
// Go mapper consumes. textView is deliberately "TextArea": the Go side treats
|
||||
// "TextArea" and "TextField" as the editable types.
|
||||
func elementTypeName(_ elementType: XCUIElement.ElementType) -> String {
|
||||
switch elementType {
|
||||
case .application: return "Application"
|
||||
case .group: return "Group"
|
||||
case .window: return "Window"
|
||||
case .sheet: return "Sheet"
|
||||
case .drawer: return "Drawer"
|
||||
case .alert: return "Alert"
|
||||
case .dialog: return "Dialog"
|
||||
case .button: return "Button"
|
||||
case .radioButton: return "RadioButton"
|
||||
case .radioGroup: return "RadioGroup"
|
||||
case .checkBox: return "CheckBox"
|
||||
case .disclosureTriangle: return "DisclosureTriangle"
|
||||
case .popUpButton: return "PopUpButton"
|
||||
case .comboBox: return "ComboBox"
|
||||
case .menuButton: return "MenuButton"
|
||||
case .toolbarButton: return "ToolbarButton"
|
||||
case .popover: return "Popover"
|
||||
case .keyboard: return "Keyboard"
|
||||
case .key: return "Key"
|
||||
case .navigationBar: return "NavigationBar"
|
||||
case .tabBar: return "TabBar"
|
||||
case .tabGroup: return "TabGroup"
|
||||
case .toolbar: return "Toolbar"
|
||||
case .statusBar: return "StatusBar"
|
||||
case .table: return "Table"
|
||||
case .tableRow: return "TableRow"
|
||||
case .tableColumn: return "TableColumn"
|
||||
case .outline: return "Outline"
|
||||
case .outlineRow: return "OutlineRow"
|
||||
case .browser: return "Browser"
|
||||
case .collectionView: return "CollectionView"
|
||||
case .slider: return "Slider"
|
||||
case .pageIndicator: return "PageIndicator"
|
||||
case .progressIndicator: return "ProgressIndicator"
|
||||
case .activityIndicator: return "ActivityIndicator"
|
||||
case .segmentedControl: return "SegmentedControl"
|
||||
case .picker: return "Picker"
|
||||
case .pickerWheel: return "PickerWheel"
|
||||
case .switch: return "Switch"
|
||||
case .toggle: return "Toggle"
|
||||
case .link: return "Link"
|
||||
case .image: return "Image"
|
||||
case .icon: return "Icon"
|
||||
case .searchField: return "SearchField"
|
||||
case .scrollView: return "ScrollView"
|
||||
case .scrollBar: return "ScrollBar"
|
||||
case .staticText: return "StaticText"
|
||||
case .textField: return "TextField"
|
||||
case .secureTextField: return "SecureTextField"
|
||||
case .datePicker: return "DatePicker"
|
||||
case .textView: return "TextArea"
|
||||
case .menu: return "Menu"
|
||||
case .menuItem: return "MenuItem"
|
||||
case .menuBar: return "MenuBar"
|
||||
case .menuBarItem: return "MenuBarItem"
|
||||
case .map: return "Map"
|
||||
case .webView: return "WebView"
|
||||
case .incrementArrow: return "IncrementArrow"
|
||||
case .decrementArrow: return "DecrementArrow"
|
||||
case .timeline: return "Timeline"
|
||||
case .ratingIndicator: return "RatingIndicator"
|
||||
case .valueIndicator: return "ValueIndicator"
|
||||
case .splitGroup: return "SplitGroup"
|
||||
case .splitter: return "Splitter"
|
||||
case .relevanceIndicator: return "RelevanceIndicator"
|
||||
case .colorWell: return "ColorWell"
|
||||
case .helpTag: return "HelpTag"
|
||||
case .matte: return "Matte"
|
||||
case .dockItem: return "DockItem"
|
||||
case .ruler: return "Ruler"
|
||||
case .rulerMarker: return "RulerMarker"
|
||||
case .grid: return "Grid"
|
||||
case .levelIndicator: return "LevelIndicator"
|
||||
case .cell: return "Cell"
|
||||
case .layoutArea: return "LayoutArea"
|
||||
case .layoutItem: return "LayoutItem"
|
||||
case .handle: return "Handle"
|
||||
case .stepper: return "Stepper"
|
||||
case .tab: return "Tab"
|
||||
case .touchBar: return "TouchBar"
|
||||
case .statusItem: return "StatusItem"
|
||||
case .any: return "Any"
|
||||
case .other: return "Other"
|
||||
@unknown default: return "Other"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import XCTest
|
||||
import CoreGraphics
|
||||
|
||||
// Synthesizes one timestamped event record from a list of primitive events.
|
||||
//
|
||||
// The synthesizer constrains how precise timing can be expressed:
|
||||
// - Within one pointer path, event offsets are honored to the millisecond,
|
||||
// but nothing after the path's first lift is delivered: a second press is
|
||||
// silently dropped, whether added through the press helper or as a raw
|
||||
// pointer event.
|
||||
// - Across paths, every event is delivered, but each path's timeline is
|
||||
// normalized to its own first event, so a later start offset collapses.
|
||||
//
|
||||
// Sequential taps therefore become one path per tap, each anchored by a raw
|
||||
// zero-offset move event at the tap point: the anchor pins the path's
|
||||
// timeline origin to the record's, turning the tap's absolute press offset
|
||||
// into an in-path delta, which is honored. This preserves the inter-tap gap
|
||||
// to the millisecond.
|
||||
enum Gesture {
|
||||
enum GestureError: Error {
|
||||
case missingField(String)
|
||||
case unknownKind(String)
|
||||
case touchUpWithoutTouchDown
|
||||
case touchDownWhileTouchActive
|
||||
case synthesisFailed(String)
|
||||
}
|
||||
|
||||
// minimumHoldSeconds keeps a tap's press observable when its down and up
|
||||
// arrive at the same offset; far below any long-press threshold.
|
||||
private static let minimumHoldSeconds = 0.03
|
||||
|
||||
// pathGapSeconds orders a press strictly after the previous lift when a
|
||||
// requested gap is shorter than the minimum hold.
|
||||
private static let pathGapSeconds = 0.005
|
||||
|
||||
// The raw shape of a touch move, read once from a probe path built with
|
||||
// the path helpers so no private enum values are hardcoded.
|
||||
private struct MoveEventShape {
|
||||
let type: Int
|
||||
let button: Int
|
||||
let clicks: UInt
|
||||
}
|
||||
|
||||
private static let moveShape: MoveEventShape = {
|
||||
let probe = XCPointerEventPath(forTouchAt: CGPoint(x: 1, y: 1), offset: 0)
|
||||
probe.move(to: CGPoint(x: 2, y: 2), atOffset: minimumHoldSeconds)
|
||||
probe.liftUp(atOffset: minimumHoldSeconds * 2)
|
||||
let events = probe.pointerEvents
|
||||
let move = events.count > 1 ? events[1] : nil
|
||||
return MoveEventShape(
|
||||
type: move?.eventType ?? 2,
|
||||
button: move?.buttonType ?? 0,
|
||||
clicks: move.map { UInt($0.clickCount) } ?? 0
|
||||
)
|
||||
}()
|
||||
|
||||
// probe reports the raw events the path helpers produce, for diagnostics.
|
||||
static func probe() -> [String: Any] {
|
||||
let path = XCPointerEventPath(forTouchAt: CGPoint(x: 1, y: 2), offset: 0)
|
||||
path.move(to: CGPoint(x: 3, y: 4), atOffset: 0.05)
|
||||
path.liftUp(atOffset: 0.1)
|
||||
return [
|
||||
"descriptions": path.pointerEvents.map { $0.description },
|
||||
"types": path.pointerEvents.map { $0.eventType },
|
||||
"buttons": path.pointerEvents.map { $0.buttonType },
|
||||
"offsets": path.pointerEvents.map { $0.offset },
|
||||
]
|
||||
}
|
||||
|
||||
// touchSegment is one press-to-lift span at absolute record offsets.
|
||||
private struct touchSegment {
|
||||
var downPoint: CGPoint
|
||||
var downOffset: Double
|
||||
var upPoint: CGPoint
|
||||
var upOffset: Double
|
||||
var movePoint: CGPoint?
|
||||
var moveOffset: Double = 0
|
||||
}
|
||||
|
||||
static func perform(events: [[String: Any]]) throws {
|
||||
var offset = 0.0
|
||||
var lastLiftOffset = -1.0
|
||||
var segments: [touchSegment] = []
|
||||
var active: touchSegment?
|
||||
|
||||
func point(_ event: [String: Any], _ xKey: String, _ yKey: String) throws -> CGPoint {
|
||||
guard let x = event[xKey] as? Double, let y = event[yKey] as? Double else {
|
||||
throw GestureError.missingField("\(xKey)/\(yKey)")
|
||||
}
|
||||
return CGPoint(x: x, y: y)
|
||||
}
|
||||
|
||||
for event in events {
|
||||
guard let kind = event["kind"] as? String else {
|
||||
throw GestureError.missingField("kind")
|
||||
}
|
||||
switch kind {
|
||||
case "touchDown":
|
||||
guard active == nil else {
|
||||
throw GestureError.touchDownWhileTouchActive
|
||||
}
|
||||
let location = try point(event, "x", "y")
|
||||
let downOffset = max(offset, lastLiftOffset + Gesture.pathGapSeconds)
|
||||
active = touchSegment(
|
||||
downPoint: location, downOffset: downOffset,
|
||||
upPoint: location, upOffset: downOffset + Gesture.minimumHoldSeconds)
|
||||
case "touchUp":
|
||||
guard var segment = active else {
|
||||
throw GestureError.touchUpWithoutTouchDown
|
||||
}
|
||||
segment.upPoint = try point(event, "x", "y")
|
||||
segment.upOffset = max(offset, segment.downOffset + Gesture.minimumHoldSeconds)
|
||||
lastLiftOffset = segment.upOffset
|
||||
segments.append(segment)
|
||||
active = nil
|
||||
case "delay":
|
||||
guard let milliseconds = event["milliseconds"] as? Double else {
|
||||
throw GestureError.missingField("milliseconds")
|
||||
}
|
||||
offset += milliseconds / 1000.0
|
||||
case "swipe":
|
||||
guard active == nil else {
|
||||
throw GestureError.touchDownWhileTouchActive
|
||||
}
|
||||
let from = try point(event, "fromX", "fromY")
|
||||
let to = try point(event, "toX", "toY")
|
||||
guard let seconds = event["seconds"] as? Double else {
|
||||
throw GestureError.missingField("seconds")
|
||||
}
|
||||
let downOffset = max(offset, lastLiftOffset + Gesture.pathGapSeconds)
|
||||
segments.append(touchSegment(
|
||||
downPoint: from, downOffset: downOffset,
|
||||
upPoint: to, upOffset: downOffset + seconds,
|
||||
movePoint: to, moveOffset: downOffset + seconds))
|
||||
lastLiftOffset = downOffset + seconds
|
||||
offset = downOffset + seconds
|
||||
default:
|
||||
throw GestureError.unknownKind(kind)
|
||||
}
|
||||
}
|
||||
if let segment = active {
|
||||
// A down without an up would leave a stuck touch on screen.
|
||||
segments.append(segment)
|
||||
}
|
||||
guard !segments.isEmpty else {
|
||||
throw GestureError.missingField("events")
|
||||
}
|
||||
|
||||
// The synthesis stack raises ObjC exceptions for invalid paths; bridge
|
||||
// them into a thrown error so the server survives a bad record.
|
||||
var caughtException: NSError?
|
||||
var synthesisError: Error?
|
||||
let completed = CompanionRunCatching({
|
||||
do {
|
||||
try Gesture.synthesize(segments: segments)
|
||||
} catch {
|
||||
synthesisError = error
|
||||
}
|
||||
}, &caughtException)
|
||||
if !completed {
|
||||
throw GestureError.synthesisFailed(caughtException?.localizedDescription ?? "unknown exception")
|
||||
}
|
||||
if let synthesisError = synthesisError {
|
||||
throw synthesisError
|
||||
}
|
||||
}
|
||||
|
||||
private static func synthesize(segments: [touchSegment]) throws {
|
||||
let record = XCSynthesizedEventRecord(name: "companion", interfaceOrientation: 0)
|
||||
for (index, segment) in segments.enumerated() {
|
||||
// Paths play back one after another with their start offsets
|
||||
// normalized away, so each path carries its events relative to its
|
||||
// own press. A trailing hover move then stretches the path out to
|
||||
// the next press's absolute offset, which preserves the requested
|
||||
// inter-tap gap under sequential playback.
|
||||
let base = segment.downOffset
|
||||
let path = XCPointerEventPath(forTouchAt: segment.downPoint, offset: 0)
|
||||
// Distinct pointer identities: without this, sequential taps at
|
||||
// the same point merge into one multi-tap gesture and the app
|
||||
// sees a single click no matter the gap.
|
||||
path.index = UInt64(index)
|
||||
if let movePoint = segment.movePoint {
|
||||
path.move(to: movePoint, atOffset: segment.moveOffset - base)
|
||||
}
|
||||
let upDelta = segment.upOffset - base
|
||||
path.liftUp(atOffset: upDelta)
|
||||
if index + 1 < segments.count {
|
||||
let tailDelta = segments[index + 1].downOffset - base
|
||||
if tailDelta > upDelta {
|
||||
path._addPointerEvent(XCPointerEvent(
|
||||
type: Gesture.moveShape.type,
|
||||
buttonType: Gesture.moveShape.button,
|
||||
coordinate: segment.upPoint,
|
||||
offset: tailDelta,
|
||||
clickCount: Gesture.moveShape.clicks))
|
||||
}
|
||||
}
|
||||
record.add(path)
|
||||
}
|
||||
|
||||
// Synchronous delivery: returns whether the event was synthesized and
|
||||
// populates the error out-pointer on failure. This avoids the async
|
||||
// completion-block path.
|
||||
try record.synthesize()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <CoreGraphics/CoreGraphics.h>
|
||||
|
||||
// Private XCTest event-synthesis interfaces. These ship inside the testing
|
||||
// frameworks bundled with Xcode and are the only path to timestamped touch
|
||||
// events with millisecond-precision offsets. The selectors below were verified
|
||||
// present in Xcode 26's XCUIAutomation framework binary.
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
// One raw pointer event inside a path: type, screen coordinate, and offset in
|
||||
// seconds. Constructed directly so a single path can carry several
|
||||
// press-and-lift pairs at exact offsets; the press-and-lift helper methods on
|
||||
// the path silently drop a second press after a lift.
|
||||
@interface XCPointerEvent : NSObject
|
||||
+ (instancetype)eventWithType:(NSInteger)eventType
|
||||
buttonType:(NSInteger)buttonType
|
||||
coordinate:(CGPoint)coordinate
|
||||
offset:(double)offset
|
||||
clickCount:(NSUInteger)clickCount;
|
||||
@property (nonatomic, readonly) NSInteger eventType;
|
||||
@property (nonatomic, readonly) NSInteger buttonType;
|
||||
@property (nonatomic, readonly) NSUInteger clickCount;
|
||||
@property (nonatomic, readonly) CGPoint coordinate;
|
||||
@property (nonatomic, readonly) double offset;
|
||||
@end
|
||||
|
||||
// One pointer's path through a synthesized event. Offsets are seconds from the
|
||||
// start of the event record.
|
||||
@interface XCPointerEventPath : NSObject
|
||||
- (instancetype)initForTouchAtPoint:(CGPoint)point offset:(double)offset;
|
||||
- (void)moveToPoint:(CGPoint)point atOffset:(double)offset;
|
||||
- (void)pressDownAtOffset:(double)offset;
|
||||
- (void)liftUpAtOffset:(double)offset;
|
||||
- (void)_addPointerEvent:(XCPointerEvent *)pointerEvent;
|
||||
@property (nonatomic, readonly) NSArray<XCPointerEvent *> *pointerEvents;
|
||||
@property (nonatomic) unsigned long long index;
|
||||
@end
|
||||
|
||||
// A complete synthesized event composed of one or more pointer paths.
|
||||
// synthesizeWithError: delivers the event synchronously and returns whether it
|
||||
// succeeded, which avoids the asynchronous completion-block path.
|
||||
@interface XCSynthesizedEventRecord : NSObject
|
||||
- (instancetype)initWithName:(NSString *)name
|
||||
interfaceOrientation:(NSInteger)interfaceOrientation;
|
||||
- (void)addPointerEventPath:(XCPointerEventPath *)pointerEventPath;
|
||||
- (BOOL)synthesizeWithError:(NSError *_Nullable *_Nullable)error;
|
||||
@end
|
||||
|
||||
// The runner-side session that delivers synthesized events to the system under
|
||||
// test.
|
||||
@interface XCTRunnerDaemonSession : NSObject
|
||||
+ (instancetype)sharedSession;
|
||||
- (void)synthesizeEvent:(XCSynthesizedEventRecord *)eventRecord
|
||||
completion:(void (^)(NSError *_Nullable error))completion;
|
||||
@end
|
||||
|
||||
// Runs the block and converts any raised NSException into an NSError so a
|
||||
// testing-framework assertion does not terminate the runner process.
|
||||
NS_INLINE BOOL CompanionRunCatching(void (^block)(void), NSError *_Nullable *_Nullable error) {
|
||||
@try {
|
||||
block();
|
||||
return YES;
|
||||
} @catch (NSException *exception) {
|
||||
if (error) {
|
||||
*error = [NSError errorWithDomain:@"dev.sanderling.companion"
|
||||
code:1
|
||||
userInfo:@{NSLocalizedDescriptionKey: exception.reason ?: exception.name}];
|
||||
}
|
||||
return NO;
|
||||
}
|
||||
}
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
@@ -0,0 +1,14 @@
|
||||
import XCTest
|
||||
|
||||
// The single long-lived test method: it starts the server and parks the runner
|
||||
// in a run loop so the simulator process stays alive serving the protocol.
|
||||
final class RunnerTestCase: XCTestCase {
|
||||
func testServeForever() throws {
|
||||
// A failed automation call must never end the test: the server has to
|
||||
// keep serving so the host can recover and retry.
|
||||
continueAfterFailure = true
|
||||
let server = try Server()
|
||||
server.start()
|
||||
RunLoop.current.run()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import UIKit
|
||||
import XCTest
|
||||
|
||||
// Newline-delimited JSON-over-TCP server. Listens on 127.0.0.1 on the port from
|
||||
// the COMPANION_PORT environment variable (default 27753). Each line is one
|
||||
// request; each reply is one line.
|
||||
final class Server {
|
||||
private let port: NWEndpoint.Port
|
||||
private let listener: NWListener
|
||||
private let queue = DispatchQueue(label: "dev.sanderling.companion.server")
|
||||
// Requests are handled off the network queue so blocking automation work
|
||||
// (snapshot, gesture synthesis) never stalls accept and receive.
|
||||
private let work = DispatchQueue(label: "dev.sanderling.companion.work")
|
||||
|
||||
// The bundle identifier of the most recently snapshotted app, used as the
|
||||
// default target for typeText when no explicit bundleId is supplied.
|
||||
private var currentBundleIdentifier = "com.apple.springboard"
|
||||
|
||||
init() throws {
|
||||
let resolvedPort = ProcessInfo.processInfo.environment["COMPANION_PORT"]
|
||||
.flatMap { UInt16($0) } ?? 27753
|
||||
self.port = NWEndpoint.Port(rawValue: resolvedPort)!
|
||||
let parameters = NWParameters.tcp
|
||||
parameters.allowLocalEndpointReuse = true
|
||||
// The simulator shares the host network stack, so an unbound listener
|
||||
// would be reachable from the host's LAN interfaces. Pin it to
|
||||
// loopback so only local processes can drive the automation surface.
|
||||
parameters.requiredLocalEndpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: self.port)
|
||||
self.listener = try NWListener(using: parameters)
|
||||
}
|
||||
|
||||
func start() {
|
||||
listener.newConnectionHandler = { [weak self] connection in
|
||||
self?.accept(connection)
|
||||
}
|
||||
listener.start(queue: queue)
|
||||
}
|
||||
|
||||
private func accept(_ connection: NWConnection) {
|
||||
connection.start(queue: queue)
|
||||
receive(on: connection, buffer: Data())
|
||||
}
|
||||
|
||||
private func receive(on connection: NWConnection, buffer: Data) {
|
||||
connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] data, _, isComplete, error in
|
||||
guard let self = self else { return }
|
||||
var working = buffer
|
||||
if let data = data {
|
||||
working.append(data)
|
||||
}
|
||||
while let newlineIndex = working.firstIndex(of: 0x0A) {
|
||||
let lineData = working.subdata(in: working.startIndex..<newlineIndex)
|
||||
working.removeSubrange(working.startIndex...newlineIndex)
|
||||
self.handleLine(lineData, on: connection)
|
||||
}
|
||||
if isComplete || error != nil {
|
||||
connection.cancel()
|
||||
return
|
||||
}
|
||||
self.receive(on: connection, buffer: working)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleLine(_ lineData: Data, on connection: NWConnection) {
|
||||
guard !lineData.isEmpty else { return }
|
||||
work.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
let requestId = (try? JSONSerialization.jsonObject(with: lineData))
|
||||
.flatMap { ($0 as? [String: Any])?["id"] as? Int } ?? 0
|
||||
do {
|
||||
let response = try self.dispatch(lineData)
|
||||
self.send(response, on: connection)
|
||||
} catch {
|
||||
self.send(["id": requestId, "error": "\(error)"], on: connection)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func dispatch(_ lineData: Data) throws -> [String: Any] {
|
||||
guard let request = try JSONSerialization.jsonObject(with: lineData) as? [String: Any] else {
|
||||
throw ServerError.malformedRequest
|
||||
}
|
||||
let requestId = request["id"] as? Int ?? 0
|
||||
guard let method = request["method"] as? String else {
|
||||
throw ServerError.malformedRequest
|
||||
}
|
||||
let params = request["params"] as? [String: Any] ?? [:]
|
||||
let result = try handle(method: method, params: params)
|
||||
return ["id": requestId, "result": result]
|
||||
}
|
||||
|
||||
private func handle(method: String, params: [String: Any]) throws -> [String: Any] {
|
||||
switch method {
|
||||
case "health":
|
||||
return ["ok": true]
|
||||
case "describe":
|
||||
return describeScreen()
|
||||
case "snapshot":
|
||||
let bundleIdentifier = params["bundleId"] as? String ?? currentBundleIdentifier
|
||||
currentBundleIdentifier = bundleIdentifier
|
||||
return ["elements": Snapshot.elements(bundleIdentifier: bundleIdentifier)]
|
||||
case "gesture":
|
||||
let events = params["events"] as? [[String: Any]] ?? []
|
||||
try Gesture.perform(events: events)
|
||||
return ["ok": true]
|
||||
case "gestureProbe":
|
||||
return Gesture.probe()
|
||||
case "launch":
|
||||
let bundleIdentifier = params["bundleId"] as? String ?? currentBundleIdentifier
|
||||
let foregroundIfRunning = params["foregroundIfRunning"] as? Bool ?? false
|
||||
try AppLifecycle.launch(bundleIdentifier: bundleIdentifier, foregroundIfRunning: foregroundIfRunning)
|
||||
return ["ok": true]
|
||||
case "terminate":
|
||||
let bundleIdentifier = params["bundleId"] as? String ?? currentBundleIdentifier
|
||||
try AppLifecycle.terminate(bundleIdentifier: bundleIdentifier)
|
||||
return ["ok": true]
|
||||
case "typeText":
|
||||
let text = params["text"] as? String ?? ""
|
||||
let replace = params["replace"] as? Bool ?? false
|
||||
let bundleIdentifier = params["bundleId"] as? String ?? currentBundleIdentifier
|
||||
try TextInput.type(text: text, replace: replace, bundleIdentifier: bundleIdentifier)
|
||||
return ["ok": true]
|
||||
case "eraseText":
|
||||
let count = params["count"] as? Int ?? 0
|
||||
let bundleIdentifier = params["bundleId"] as? String ?? currentBundleIdentifier
|
||||
try TextInput.erase(count: count, bundleIdentifier: bundleIdentifier)
|
||||
return ["ok": true]
|
||||
case "pressKey":
|
||||
let key = params["key"] as? String ?? ""
|
||||
let bundleIdentifier = params["bundleId"] as? String ?? currentBundleIdentifier
|
||||
try TextInput.pressKey(key: key, bundleIdentifier: bundleIdentifier)
|
||||
return ["ok": true]
|
||||
case "appState":
|
||||
let bundleIdentifier = params["bundleId"] as? String ?? currentBundleIdentifier
|
||||
return ["state": AppLifecycle.state(bundleIdentifier: bundleIdentifier)]
|
||||
case "screenshot":
|
||||
return try screenshot()
|
||||
default:
|
||||
throw ServerError.unknownMethod(method)
|
||||
}
|
||||
}
|
||||
|
||||
private func describeScreen() -> [String: Any] {
|
||||
// The runner process has no foreground scene, so UIScreen.main.bounds
|
||||
// returns a fallback size. The springboard application snapshot frame is
|
||||
// the device size in points; the screen scale comes from UIScreen.
|
||||
var scale: CGFloat = 1
|
||||
let collect = { scale = UIScreen.main.scale }
|
||||
if Thread.isMainThread {
|
||||
collect()
|
||||
} else {
|
||||
DispatchQueue.main.sync(execute: collect)
|
||||
}
|
||||
var width = 0
|
||||
var height = 0
|
||||
if let root = Snapshot.elements(bundleIdentifier: "com.apple.springboard").first,
|
||||
let frame = root["frame"] as? [String: Any] {
|
||||
width = Int(frame["width"] as? Double ?? 0)
|
||||
height = Int(frame["height"] as? Double ?? 0)
|
||||
}
|
||||
return [
|
||||
"widthPoints": width,
|
||||
"heightPoints": height,
|
||||
"scale": Double(scale),
|
||||
]
|
||||
}
|
||||
|
||||
private func screenshot() throws -> [String: Any] {
|
||||
let image = XCUIScreen.main.screenshot()
|
||||
return ["pngBase64": image.pngRepresentation.base64EncodedString()]
|
||||
}
|
||||
|
||||
private func send(_ object: [String: Any], on connection: NWConnection) {
|
||||
guard var data = try? JSONSerialization.data(withJSONObject: object) else {
|
||||
return
|
||||
}
|
||||
data.append(0x0A)
|
||||
connection.send(content: data, completion: .contentProcessed { _ in })
|
||||
}
|
||||
|
||||
enum ServerError: Error {
|
||||
case malformedRequest
|
||||
case unknownMethod(String)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import XCTest
|
||||
|
||||
// Flattens an accessibility snapshot tree into the legacy flat element array.
|
||||
enum Snapshot {
|
||||
// Produces the flat element array for the given bundle identifier. Falls back
|
||||
// to springboard if the requested app is not running or its snapshot throws.
|
||||
// Snapshot resolution can demand the test's execution context, which lives
|
||||
// on the main thread, and a framework assertion raises an exception that
|
||||
// would kill the server; so the capture runs on main inside the catch
|
||||
// bridge, and an assertion yields an empty dump, which upstream treats as
|
||||
// collapsed and retries.
|
||||
static func elements(bundleIdentifier: String) -> [[String: Any]] {
|
||||
var result: [[String: Any]] = []
|
||||
var caughtError: NSError?
|
||||
let work = {
|
||||
_ = CompanionRunCatching({
|
||||
if let captured = try? capture(bundleIdentifier: bundleIdentifier) {
|
||||
result = captured
|
||||
} else if let fallback = try? capture(bundleIdentifier: "com.apple.springboard") {
|
||||
result = fallback
|
||||
}
|
||||
}, &caughtError)
|
||||
}
|
||||
if Thread.isMainThread {
|
||||
work()
|
||||
} else {
|
||||
DispatchQueue.main.sync(execute: work)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private static func capture(bundleIdentifier: String) throws -> [[String: Any]] {
|
||||
let application = XCUIApplication(bundleIdentifier: bundleIdentifier)
|
||||
let root = try application.snapshot()
|
||||
var result: [[String: Any]] = []
|
||||
walk(root, into: &result)
|
||||
return result
|
||||
}
|
||||
|
||||
private static func walk(_ node: XCUIElementSnapshot, into result: inout [[String: Any]]) {
|
||||
// The keyboard subtree is pruned: the legacy accessibility bridge
|
||||
// never exposed it, its key frames are unreliable as tap targets, and
|
||||
// its shift-state churn destabilizes settle hashing.
|
||||
if node.elementType == .keyboard {
|
||||
return
|
||||
}
|
||||
result.append(serialize(node))
|
||||
for child in node.children {
|
||||
walk(child, into: &result)
|
||||
}
|
||||
}
|
||||
|
||||
private static func serialize(_ node: XCUIElementSnapshot) -> [String: Any] {
|
||||
let frame = node.frame
|
||||
return [
|
||||
"type": elementTypeName(node.elementType),
|
||||
"frame": [
|
||||
"x": Double(frame.origin.x),
|
||||
"y": Double(frame.origin.y),
|
||||
"width": Double(frame.size.width),
|
||||
"height": Double(frame.size.height),
|
||||
],
|
||||
"enabled": node.isEnabled,
|
||||
"AXLabel": nullableString(node.label),
|
||||
"AXValue": nullableString(stringifyValue(node.value)),
|
||||
"AXUniqueId": nullableString(node.identifier),
|
||||
]
|
||||
}
|
||||
|
||||
private static func nullableString(_ value: String?) -> Any {
|
||||
guard let value = value, !value.isEmpty else { return NSNull() }
|
||||
return value
|
||||
}
|
||||
|
||||
private static func stringifyValue(_ value: Any?) -> String? {
|
||||
guard let value = value else { return nil }
|
||||
if let text = value as? String { return text }
|
||||
return String(describing: value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import XCTest
|
||||
|
||||
// Types unicode text into the focused element through the public typeText API,
|
||||
// avoiding the pasteboard entirely. With replace enabled, the current value
|
||||
// length is consumed first by prepending that many delete keys so the whole
|
||||
// replacement happens in one typeText call.
|
||||
enum TextInput {
|
||||
enum TextInputError: Error {
|
||||
case typingFailed(String)
|
||||
}
|
||||
|
||||
static func type(text: String, replace: Bool, bundleIdentifier: String) throws {
|
||||
let application = XCUIApplication(bundleIdentifier: bundleIdentifier)
|
||||
|
||||
var payload = text
|
||||
if replace {
|
||||
let deleteCount = deletePrefixLength(bundleIdentifier: bundleIdentifier)
|
||||
if deleteCount > 0 {
|
||||
let deletes = String(repeating: XCUIKeyboardKey.delete.rawValue, count: deleteCount)
|
||||
payload = deletes + text
|
||||
}
|
||||
}
|
||||
|
||||
// Type at the application level so it lands in whatever holds keyboard
|
||||
// focus, without resolving a specific element. Compose Multiplatform
|
||||
// text fields do not reliably expose hasKeyboardFocus to the query layer.
|
||||
// typeText must run on the main thread.
|
||||
var caughtError: NSError?
|
||||
var completed = false
|
||||
runOnMain {
|
||||
completed = CompanionRunCatching({
|
||||
application.typeText(payload)
|
||||
}, &caughtError)
|
||||
}
|
||||
if !completed {
|
||||
throw TextInputError.typingFailed(caughtError?.localizedDescription ?? "unknown")
|
||||
}
|
||||
}
|
||||
|
||||
// erase deletes count characters from the focused field by typing that many
|
||||
// delete keys. Used on device, where EraseText routes to the runner instead
|
||||
// of the legacy companion's HID stream.
|
||||
static func erase(count: Int, bundleIdentifier: String) throws {
|
||||
guard count > 0 else { return }
|
||||
let deletes = String(repeating: XCUIKeyboardKey.delete.rawValue, count: count)
|
||||
try typeOnFocus(deletes, bundleIdentifier: bundleIdentifier)
|
||||
}
|
||||
|
||||
// pressKey types a single logical key into the focused field. Only return is
|
||||
// supported, matching the simulator companion's key surface.
|
||||
static func pressKey(key: String, bundleIdentifier: String) throws {
|
||||
switch key {
|
||||
case "return", "enter", "Return", "Enter":
|
||||
try typeOnFocus(XCUIKeyboardKey.return.rawValue, bundleIdentifier: bundleIdentifier)
|
||||
default:
|
||||
throw TextInputError.typingFailed("unsupported key \(key)")
|
||||
}
|
||||
}
|
||||
|
||||
private static func typeOnFocus(_ payload: String, bundleIdentifier: String) throws {
|
||||
let application = XCUIApplication(bundleIdentifier: bundleIdentifier)
|
||||
var caughtError: NSError?
|
||||
var completed = false
|
||||
runOnMain {
|
||||
completed = CompanionRunCatching({
|
||||
application.typeText(payload)
|
||||
}, &caughtError)
|
||||
}
|
||||
if !completed {
|
||||
throw TextInputError.typingFailed(caughtError?.localizedDescription ?? "unknown")
|
||||
}
|
||||
}
|
||||
|
||||
private static func runOnMain(_ work: () -> Void) {
|
||||
if Thread.isMainThread {
|
||||
work()
|
||||
} else {
|
||||
DispatchQueue.main.sync(execute: work)
|
||||
}
|
||||
}
|
||||
|
||||
// Sizes the delete prefix for replace. The snapshot does not say which
|
||||
// editable field holds keyboard focus (Compose fields never expose
|
||||
// hasKeyboardFocus), so the prefix covers the longest editable value on
|
||||
// screen: deletes beyond the focused field's content are no-ops at the
|
||||
// start of the field, while undersizing would leave residue and silently
|
||||
// turn replace into append.
|
||||
private static func deletePrefixLength(bundleIdentifier: String) -> Int {
|
||||
let elements = Snapshot.elements(bundleIdentifier: bundleIdentifier)
|
||||
var longest = 0
|
||||
for element in elements {
|
||||
guard let type = element["type"] as? String,
|
||||
type == "TextArea" || type == "TextField",
|
||||
let value = element["AXValue"] as? String else {
|
||||
continue
|
||||
}
|
||||
longest = max(longest, value.count)
|
||||
}
|
||||
return longest
|
||||
}
|
||||
}
|
||||
Executable
+74
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Builds the embeddable simulator runner tarball.
|
||||
#
|
||||
# Regenerates the Xcode project, builds the UI test bundle for a generic
|
||||
# simulator destination (so no booted simulator is needed), then stages a
|
||||
# relocatable test root: the runner app alongside its xctestrun. A port
|
||||
# placeholder is injected into the xctestrun so the driver can substitute the
|
||||
# real port before launching. The staged tree is packaged as a tarball.
|
||||
#
|
||||
# Output: ../internal/driver/ioscompanion/runnerassets/assets/runner-1.0.0.tar.gz
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Keep the archiver from emitting AppleDouble (._name) sidecar entries that
|
||||
# would otherwise duplicate extended attributes into the payload.
|
||||
export COPYFILE_DISABLE=1
|
||||
|
||||
VERSION="1.0.0"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ASSETS_DIR="${SCRIPT_DIR}/../internal/driver/ioscompanion/runnerassets/assets"
|
||||
OUTPUT="${ASSETS_DIR}/runner-${VERSION}.tar.gz"
|
||||
|
||||
cd "${SCRIPT_DIR}"
|
||||
|
||||
# Regenerate the project from its spec. The .xcodeproj is gitignored, so a
|
||||
# fresh checkout must produce it before building.
|
||||
/opt/homebrew/bin/xcodegen --spec project.yml
|
||||
|
||||
# Build the UI test bundle for a generic simulator destination so no booted
|
||||
# simulator is required; products land under build/Build/Products/.
|
||||
xcodebuild build-for-testing \
|
||||
-project CompanionRunner.xcodeproj \
|
||||
-scheme CompanionRunner \
|
||||
-destination 'generic/platform=iOS Simulator' \
|
||||
-derivedDataPath build \
|
||||
CODE_SIGNING_ALLOWED=NO
|
||||
|
||||
PRODUCTS="${SCRIPT_DIR}/build/Build/Products"
|
||||
|
||||
STAGE="$(mktemp -d)"
|
||||
trap 'rm -rf "${STAGE}"' EXIT
|
||||
|
||||
# The xctestrun name embeds the simulator SDK version, so resolve it rather
|
||||
# than hardcoding it.
|
||||
XCTESTRUN="$(find "${PRODUCTS}" -maxdepth 1 -name '*.xctestrun' -print -quit)"
|
||||
if [ -z "${XCTESTRUN}" ]; then
|
||||
echo "no xctestrun under ${PRODUCTS}" >&2
|
||||
exit 1
|
||||
fi
|
||||
cp "${XCTESTRUN}" "${STAGE}/runner.xctestrun"
|
||||
|
||||
# Copy the runner app into the staged products directory. The .swiftmodule
|
||||
# sibling is build-time only and is not copied.
|
||||
mkdir -p "${STAGE}/Debug-iphonesimulator"
|
||||
cp -R \
|
||||
"${PRODUCTS}/Debug-iphonesimulator/CompanionRunnerUITests-Runner.app" \
|
||||
"${STAGE}/Debug-iphonesimulator/CompanionRunnerUITests-Runner.app"
|
||||
|
||||
# Inject the port placeholder the driver substitutes at launch time.
|
||||
/usr/libexec/PlistBuddy \
|
||||
-c "Add :CompanionRunnerUITests:EnvironmentVariables:COMPANION_PORT string __COMPANION_PORT__" \
|
||||
"${STAGE}/runner.xctestrun"
|
||||
|
||||
STAGED_BYTES="$(find "${STAGE}" -type f -exec stat -f%z {} + | awk '{sum += $1} END {print sum}')"
|
||||
|
||||
mkdir -p "${ASSETS_DIR}"
|
||||
tar -czf "${OUTPUT}" -C "${STAGE}" .
|
||||
|
||||
SHA="$(shasum -a 256 "${OUTPUT}" | awk '{print $1}')"
|
||||
|
||||
echo "wrote ${OUTPUT}"
|
||||
echo "staged uncompressed size: ${STAGED_BYTES} bytes"
|
||||
echo "sha256: ${SHA}"
|
||||
@@ -0,0 +1,36 @@
|
||||
name: CompanionRunner
|
||||
options:
|
||||
bundleIdPrefix: dev.sanderling
|
||||
deploymentTarget:
|
||||
iOS: "16.0"
|
||||
createIntermediateGroups: true
|
||||
|
||||
settings:
|
||||
base:
|
||||
CODE_SIGNING_ALLOWED: "NO"
|
||||
CODE_SIGNING_REQUIRED: "NO"
|
||||
SWIFT_VERSION: "5.0"
|
||||
|
||||
targets:
|
||||
CompanionRunnerUITests:
|
||||
type: bundle.ui-testing
|
||||
platform: iOS
|
||||
productName: CompanionRunnerUITests
|
||||
sources:
|
||||
- Sources
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: dev.sanderling.companion
|
||||
SWIFT_OBJC_BRIDGING_HEADER: Sources/PrivateAutomation/PrivateAutomation.h
|
||||
OTHER_LDFLAGS:
|
||||
- "-framework"
|
||||
- "XCTest"
|
||||
|
||||
schemes:
|
||||
CompanionRunner:
|
||||
build:
|
||||
targets:
|
||||
CompanionRunnerUITests: all
|
||||
test:
|
||||
targets:
|
||||
- CompanionRunnerUITests
|
||||
@@ -0,0 +1 @@
|
||||
runs/
|
||||
Executable
+412
@@ -0,0 +1,412 @@
|
||||
#!/usr/bin/env bash
|
||||
# Scripted conformance gate for the iOS simulator driver. Runs five serial,
|
||||
# non-overlapping 3-minute fuzz runs against the folio example app
|
||||
# (examples/folio) and scores five gates (G1..G5) over the captured traces and
|
||||
# output. Exits non-zero if any gate fails.
|
||||
#
|
||||
# Backends:
|
||||
# BACKEND=simulator (default) drive the booted iOS simulator
|
||||
# BACKEND=device drive an attached physical iPhone via the
|
||||
# driver's runner-only device path; select it
|
||||
# with IOS_DEVICE="<name>" (passed as --ios-device)
|
||||
#
|
||||
# Usage:
|
||||
# ./gates.sh run the simulator gates
|
||||
# BACKEND=device IOS_DEVICE="iPhone" ./gates.sh
|
||||
# ./gates.sh --self-test run the offline analyzer tests only
|
||||
#
|
||||
# Tunables (environment):
|
||||
# RUNS=5 number of serial runs
|
||||
# DURATION=3m per-run fuzz duration
|
||||
# SEED=0 fuzz seed
|
||||
# P95_LIMIT_MS=2500 G5 p95 step-latency ceiling in milliseconds
|
||||
# SANDERLING=sanderling binary to invoke
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
script_directory="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
folio_directory="$(cd "${script_directory}/../examples/folio" && pwd)"
|
||||
|
||||
BACKEND="${BACKEND:-simulator}"
|
||||
RUNS="${RUNS:-5}"
|
||||
DURATION="${DURATION:-3m}"
|
||||
SEED="${SEED:-0}"
|
||||
P95_LIMIT_MS="${P95_LIMIT_MS:-2500}"
|
||||
SANDERLING="${SANDERLING:-sanderling}"
|
||||
IOS_DEVICE="${IOS_DEVICE:-iPhone 17 Pro}"
|
||||
|
||||
bundle_id="app.folio"
|
||||
spec_path="${folio_directory}/sanderling/spec.ts"
|
||||
# The built app bundle differs by SDK: the simulator build lands under
|
||||
# Debug-iphonesimulator, the device build under Debug-iphoneos.
|
||||
if [[ "$BACKEND" == "device" ]]; then
|
||||
ios_app="${folio_directory}/app/iosApp/build/Build/Products/Debug-iphoneos/iosApp.app"
|
||||
else
|
||||
ios_app="${folio_directory}/app/iosApp/build/Build/Products/Debug-iphonesimulator/iosApp.app"
|
||||
fi
|
||||
|
||||
# The companion binary, embedded for simulator runs. Referenced by file name
|
||||
# only for the orphan-process check; prose elsewhere says "the companion".
|
||||
companion_process_name="idb_companion"
|
||||
|
||||
# Known-benign stderr lines the companion always prints. These are matched as
|
||||
# fixed substrings and excluded from the G2 ERROR scan. Keep this list tight:
|
||||
# only lines that are provably harmless and emitted on every healthy run.
|
||||
benign_stderr_substrings=(
|
||||
# The dynamic linker reports the same Objective-C class registered by two
|
||||
# loaded images. The companion runs fine; this is cosmetic. The real line
|
||||
# reads "objc[<pid>]: Class ...", so the fixed substring is "]: Class".
|
||||
"]: Class"
|
||||
"is implemented in both"
|
||||
"One of the two will be used. Which one is undefined."
|
||||
# gRPC and absl emit informational banner lines on startup.
|
||||
"WARNING: All log messages before absl::InitializeLog()"
|
||||
)
|
||||
|
||||
# ---- gate analyzers (pure, operate on a single run directory) --------------
|
||||
|
||||
# G2 helper: strip benign companion noise, then report sanderling ERROR lines.
|
||||
# sanderling's progress logger renders error-level records as lines beginning
|
||||
# "error:" (see internal/testrun/progress.go). We also catch the upper-case
|
||||
# ERROR token (word-bounded, so ERRORS/ERRORLESS and path fragments do not
|
||||
# trip the gate) for safety against future handlers.
|
||||
error_lines_in() {
|
||||
local output_file="$1"
|
||||
local filtered
|
||||
filtered="$(cat "$output_file")"
|
||||
local pattern
|
||||
for pattern in "${benign_stderr_substrings[@]}"; do
|
||||
filtered="$(printf '%s\n' "$filtered" | grep -vF "$pattern" || true)"
|
||||
done
|
||||
printf '%s\n' "$filtered" | grep -E '(^error:|\bERROR\b)' || true
|
||||
}
|
||||
|
||||
# G1: process exit status recorded by the runner loop.
|
||||
gate_exit_zero() {
|
||||
local run_directory="$1"
|
||||
[[ "$(cat "${run_directory}/exit_status")" == "0" ]]
|
||||
}
|
||||
|
||||
# G2: no sanderling ERROR lines after filtering benign companion noise.
|
||||
gate_no_error_lines() {
|
||||
local run_directory="$1"
|
||||
local found
|
||||
found="$(error_lines_in "${run_directory}/output.log")"
|
||||
[[ -z "$found" ]]
|
||||
}
|
||||
|
||||
# G3: the first hierarchy snapshot shows the login screen with empty email and
|
||||
# password fields (clear-state proof). Reads the first trace line carrying a
|
||||
# hierarchy and asserts LoginEmail/LoginPassword carry no text.
|
||||
gate_clear_state() {
|
||||
local run_directory="$1"
|
||||
local trace_file="${run_directory}/trace.jsonl"
|
||||
[[ -f "$trace_file" ]] || return 1
|
||||
local verdict
|
||||
verdict="$(jq -s -r '
|
||||
[ .[] | select(.hierarchy != null) ] as $withHierarchy
|
||||
| if ($withHierarchy | length) == 0 then "fail:no-hierarchy"
|
||||
else ($withHierarchy[0].hierarchy.elements // []) as $elements
|
||||
| ($elements | map(select(.resourceId == "LoginEmail")) | first) as $email
|
||||
| ($elements | map(select(.resourceId == "LoginPassword")) | first) as $password
|
||||
| if $email == null or $password == null then "fail:no-login-fields"
|
||||
elif (($email.attrs.text // $email.text // "") != "") then "fail:email-not-empty"
|
||||
elif (($password.attrs.text // $password.text // "") != "") then "fail:password-not-empty"
|
||||
else "pass" end
|
||||
end
|
||||
' "$trace_file")"
|
||||
[[ "$verdict" == "pass" ]]
|
||||
}
|
||||
|
||||
# G4: no doubled text after InputText. For each InputText action targeting a
|
||||
# field, the field's value in the NEXT hierarchy must not contain the input
|
||||
# concatenated with itself (catches append-vs-replace and double-paste bugs).
|
||||
# The action chosen at step N is applied before step N+1 is observed, so the
|
||||
# effect lands in the following snapshot.
|
||||
gate_no_doubled_text() {
|
||||
local run_directory="$1"
|
||||
local trace_file="${run_directory}/trace.jsonl"
|
||||
[[ -f "$trace_file" ]] || return 1
|
||||
local verdict
|
||||
verdict="$(jq -s -r '
|
||||
# Map a selector like "testTag:LoginScreen > testTag:LoginEmail" to its
|
||||
# target field id: the token after the final ":" of the last segment.
|
||||
def target_field(selector):
|
||||
(selector | split(">") | last | gsub("^\\s+|\\s+$";"")) as $last
|
||||
| ($last | split(":") | last);
|
||||
|
||||
[ .[] | select(.hierarchy != null) ] as $steps
|
||||
| reduce range(0; ($steps | length)) as $i ([];
|
||||
($steps[$i]) as $current
|
||||
| (if $i + 1 < ($steps | length) then $steps[$i + 1] else null end) as $next
|
||||
| if ($current.next_action.kind == "InputText") and ($next != null)
|
||||
then
|
||||
(target_field($current.next_action.selector // "")) as $field
|
||||
| ($current.next_action.text // "") as $typed
|
||||
| (($next.hierarchy.elements // [])
|
||||
| map(select(.resourceId == $field)) | first) as $element
|
||||
| if $element != null and $typed != ""
|
||||
then
|
||||
(($element.attrs.text // $element.text // "")) as $value
|
||||
| if ($value | contains($typed + $typed))
|
||||
then . + [{field: $field, typed: $typed, value: $value}]
|
||||
else . end
|
||||
else . end
|
||||
else . end)
|
||||
| if length == 0 then "pass"
|
||||
else "fail:" + (.[0].field) + ":" + (.[0].value) end
|
||||
' "$trace_file")"
|
||||
[[ "$verdict" == "pass" ]]
|
||||
}
|
||||
|
||||
# Emit one step-latency sample per consecutive trace-step pair, in
|
||||
# milliseconds, on stdout. The trace records a wall-clock timestamp per step;
|
||||
# the latency of a step is the gap to the next step's observation. Used by G5,
|
||||
# which aggregates samples across all runs before computing the p95. Timestamps
|
||||
# are RFC3339 with fractional seconds and a numeric offset, so they are parsed
|
||||
# with python3 rather than jq's UTC-only fromdateiso8601.
|
||||
emit_step_latencies() {
|
||||
local trace_file="$1"
|
||||
[[ -f "$trace_file" ]] || return 0
|
||||
jq -r 'select(.timestamp != null) | .timestamp' "$trace_file" \
|
||||
| python3 -c '
|
||||
import sys
|
||||
from datetime import datetime
|
||||
stamps = [datetime.fromisoformat(line.strip()) for line in sys.stdin if line.strip()]
|
||||
for earlier, later in zip(stamps, stamps[1:]):
|
||||
print(int((later - earlier).total_seconds() * 1000))
|
||||
'
|
||||
}
|
||||
|
||||
# Compute the p95 (nearest-rank) of the latency samples on stdin, in ms.
|
||||
p95_of() {
|
||||
python3 -c '
|
||||
import sys, math
|
||||
samples = sorted(int(float(line)) for line in sys.stdin if line.strip())
|
||||
if not samples:
|
||||
print(0)
|
||||
sys.exit(0)
|
||||
rank = max(1, math.ceil(0.95 * len(samples)))
|
||||
print(samples[rank - 1])
|
||||
'
|
||||
}
|
||||
|
||||
# G5 orphan check: report any lingering companion, runner session (the hybrid
|
||||
# simulator driver hosts an in-simulator runner), and, on the device backend,
|
||||
# the device runner session. The usbmux tunnel is an in-process forwarder that
|
||||
# dies with sanderling, so it leaves no process to check. Empty output is clean.
|
||||
orphan_processes() {
|
||||
local found=""
|
||||
if pgrep -f "$companion_process_name" >/dev/null 2>&1; then
|
||||
found+="companion "
|
||||
fi
|
||||
if pgrep -f "sanderling-runner.*xctestrun" >/dev/null 2>&1; then
|
||||
found+="runner-session "
|
||||
fi
|
||||
if pgrep -f "CompanionRunnerUITests-Runner" >/dev/null 2>&1; then
|
||||
found+="runner-app "
|
||||
fi
|
||||
if [[ "$BACKEND" == "device" ]]; then
|
||||
# The device test session that hosts the runner. Its destination carries
|
||||
# platform=iOS,id=<udid>.
|
||||
if pgrep -f "xctestrun.*platform=iOS,id=" >/dev/null 2>&1; then
|
||||
found+="device-session "
|
||||
fi
|
||||
fi
|
||||
printf '%s' "$found"
|
||||
}
|
||||
|
||||
# ---- run orchestration -----------------------------------------------------
|
||||
|
||||
invoke_sanderling() {
|
||||
local output_directory="$1"
|
||||
local output_log="$2"
|
||||
local exit_status_file="$3"
|
||||
|
||||
# Both backends pass --ios-app-path so each run reinstalls the current build
|
||||
# for a clean clear-state start (device install via devicectl, simulator via
|
||||
# simctl). The device backend selects the connected iPhone by name; the
|
||||
# simulator backend boots IOS_DEVICE if nothing is booted.
|
||||
local target_flags=(--ios-device "$IOS_DEVICE" --ios-app-path "$ios_app")
|
||||
|
||||
local status=0
|
||||
"$SANDERLING" test \
|
||||
--platform ios \
|
||||
--spec "$spec_path" \
|
||||
--bundle-id "$bundle_id" \
|
||||
"${target_flags[@]}" \
|
||||
--duration "$DURATION" \
|
||||
--seed "$SEED" \
|
||||
--output "$output_directory" \
|
||||
>"$output_log" 2>&1 || status=$?
|
||||
printf '%s' "$status" >"$exit_status_file"
|
||||
}
|
||||
|
||||
# Locate the run directory sanderling created under output_directory (it nests
|
||||
# a timestamped subdirectory) and normalise its artifacts up one level.
|
||||
collect_run_artifacts() {
|
||||
local output_directory="$1"
|
||||
[[ -f "${output_directory}/trace.jsonl" ]] && return 0
|
||||
local produced
|
||||
produced="$(find "$output_directory" -mindepth 2 -name 'trace.jsonl' 2>/dev/null | head -1 || true)"
|
||||
if [[ -n "$produced" ]]; then
|
||||
cp "$produced" "${output_directory}/trace.jsonl" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
run_gates() {
|
||||
if [[ "$BACKEND" == "simulator" ]]; then
|
||||
echo "preparing folio build for the simulator backend"
|
||||
( cd "$folio_directory" && just ios >/dev/null )
|
||||
else
|
||||
echo "preparing folio device build"
|
||||
( cd "$folio_directory" && just ios-device >/dev/null )
|
||||
fi
|
||||
|
||||
local timestamp
|
||||
timestamp="$(date +%Y%m%d-%H%M%S)"
|
||||
local gate_root="${script_directory}/runs/${timestamp}"
|
||||
mkdir -p "$gate_root"
|
||||
echo "gate root: ${gate_root}"
|
||||
echo "backend=${BACKEND} runs=${RUNS} duration=${DURATION} seed=${SEED} p95_limit_ms=${P95_LIMIT_MS}"
|
||||
|
||||
local all_latencies="${gate_root}/all-latencies.txt"
|
||||
: >"$all_latencies"
|
||||
|
||||
local -a g1 g2 g3 g4 g5
|
||||
local run_index
|
||||
for ((run_index = 1; run_index <= RUNS; run_index++)); do
|
||||
local run_directory="${gate_root}/run-${run_index}"
|
||||
mkdir -p "$run_directory"
|
||||
echo "run ${run_index}/${RUNS} -> ${run_directory}"
|
||||
|
||||
invoke_sanderling "$run_directory" "${run_directory}/output.log" "${run_directory}/exit_status"
|
||||
collect_run_artifacts "$run_directory"
|
||||
|
||||
g1[run_index]=$(gate_exit_zero "$run_directory" && echo PASS || echo FAIL)
|
||||
g2[run_index]=$(gate_no_error_lines "$run_directory" && echo PASS || echo FAIL)
|
||||
g3[run_index]=$(gate_clear_state "$run_directory" && echo PASS || echo FAIL)
|
||||
g4[run_index]=$(gate_no_doubled_text "$run_directory" && echo PASS || echo FAIL)
|
||||
|
||||
emit_step_latencies "${run_directory}/trace.jsonl" >>"$all_latencies"
|
||||
|
||||
local orphans
|
||||
orphans="$(orphan_processes)"
|
||||
if [[ -n "$orphans" ]]; then
|
||||
g5[run_index]="FAIL"
|
||||
echo " orphaned processes after run ${run_index}: ${orphans}"
|
||||
else
|
||||
g5[run_index]="PENDING"
|
||||
fi
|
||||
done
|
||||
|
||||
local p95
|
||||
p95="$(p95_of <"$all_latencies")"
|
||||
local final_orphans
|
||||
final_orphans="$(orphan_processes)"
|
||||
|
||||
# G5 is global: p95 is computed over every run's samples and an orphan after
|
||||
# any single run fails the whole gate. Decide once, then stamp every row.
|
||||
local g5_global="PASS"
|
||||
if [[ "$p95" -ge "$P95_LIMIT_MS" ]]; then
|
||||
g5_global="FAIL"
|
||||
fi
|
||||
if [[ -n "$final_orphans" ]]; then
|
||||
g5_global="FAIL"
|
||||
echo "orphaned processes at end: ${final_orphans}"
|
||||
fi
|
||||
for ((run_index = 1; run_index <= RUNS; run_index++)); do
|
||||
if [[ "${g5[run_index]}" == "FAIL" ]]; then
|
||||
g5_global="FAIL"
|
||||
fi
|
||||
done
|
||||
for ((run_index = 1; run_index <= RUNS; run_index++)); do
|
||||
g5[run_index]="$g5_global"
|
||||
done
|
||||
|
||||
echo
|
||||
printf 'run G1 G2 G3 G4 G5\n'
|
||||
local verdict="PASS"
|
||||
for ((run_index = 1; run_index <= RUNS; run_index++)); do
|
||||
printf '%-4s %-5s %-5s %-5s %-5s %-5s\n' \
|
||||
"$run_index" "${g1[run_index]}" "${g2[run_index]}" "${g3[run_index]}" "${g4[run_index]}" "${g5[run_index]}"
|
||||
for cell in "${g1[run_index]}" "${g2[run_index]}" "${g3[run_index]}" "${g4[run_index]}" "${g5[run_index]}"; do
|
||||
[[ "$cell" == "PASS" ]] || verdict="FAIL"
|
||||
done
|
||||
done
|
||||
echo
|
||||
echo "p95 step latency: ${p95}ms (limit ${P95_LIMIT_MS}ms)"
|
||||
if [[ "$verdict" == "PASS" ]]; then
|
||||
echo "GATES PASS"
|
||||
return 0
|
||||
fi
|
||||
echo "GATES FAIL"
|
||||
return 1
|
||||
}
|
||||
|
||||
# ---- offline self-test -----------------------------------------------------
|
||||
# Exercises every analyzer against canned passing/failing run directories under
|
||||
# testdata/ so the parsing logic can be checked without a device.
|
||||
|
||||
self_test() {
|
||||
local testdata="${script_directory}/testdata"
|
||||
local failures=0
|
||||
|
||||
assert() {
|
||||
local label="$1" expected="$2" actual="$3"
|
||||
if [[ "$expected" == "$actual" ]]; then
|
||||
printf 'ok %s\n' "$label"
|
||||
else
|
||||
printf 'FAIL %s (expected %s, got %s)\n' "$label" "$expected" "$actual"
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
assert "G1 pass run exits zero" PASS \
|
||||
"$(gate_exit_zero "${testdata}/pass" && echo PASS || echo FAIL)"
|
||||
assert "G1 fail run nonzero exit" FAIL \
|
||||
"$(gate_exit_zero "${testdata}/g1-nonzero-exit" && echo PASS || echo FAIL)"
|
||||
|
||||
assert "G2 pass run no errors" PASS \
|
||||
"$(gate_no_error_lines "${testdata}/pass" && echo PASS || echo FAIL)"
|
||||
assert "G2 benign noise tolerated" PASS \
|
||||
"$(gate_no_error_lines "${testdata}/g2-benign-only" && echo PASS || echo FAIL)"
|
||||
assert "G2 real error caught" FAIL \
|
||||
"$(gate_no_error_lines "${testdata}/g2-real-error" && echo PASS || echo FAIL)"
|
||||
|
||||
assert "G3 pass clear state" PASS \
|
||||
"$(gate_clear_state "${testdata}/pass" && echo PASS || echo FAIL)"
|
||||
assert "G3 dirty fields caught" FAIL \
|
||||
"$(gate_clear_state "${testdata}/g3-dirty-field" && echo PASS || echo FAIL)"
|
||||
|
||||
assert "G4 pass no doubling" PASS \
|
||||
"$(gate_no_doubled_text "${testdata}/pass" && echo PASS || echo FAIL)"
|
||||
assert "G4 doubled text caught" FAIL \
|
||||
"$(gate_no_doubled_text "${testdata}/g4-doubled-text" && echo PASS || echo FAIL)"
|
||||
|
||||
local pass_p95 slow_p95
|
||||
pass_p95="$(emit_step_latencies "${testdata}/pass/trace.jsonl" | p95_of)"
|
||||
slow_p95="$(emit_step_latencies "${testdata}/g5-slow-p95/trace.jsonl" | p95_of)"
|
||||
assert "G5 pass p95 under limit" PASS \
|
||||
"$([[ "$pass_p95" -lt "$P95_LIMIT_MS" ]] && echo PASS || echo FAIL)"
|
||||
assert "G5 slow p95 over limit" FAIL \
|
||||
"$([[ "$slow_p95" -lt "$P95_LIMIT_MS" ]] && echo PASS || echo FAIL)"
|
||||
|
||||
echo
|
||||
if [[ "$failures" -eq 0 ]]; then
|
||||
echo "SELF-TEST PASS"
|
||||
return 0
|
||||
fi
|
||||
echo "SELF-TEST FAIL (${failures} failed)"
|
||||
return 1
|
||||
}
|
||||
|
||||
main() {
|
||||
case "${1:-}" in
|
||||
--self-test) self_test ;;
|
||||
"") run_gates ;;
|
||||
*) echo "usage: $0 [--self-test]" >&2; exit 2 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -0,0 +1 @@
|
||||
1
|
||||
@@ -0,0 +1 @@
|
||||
run complete
|
||||
@@ -0,0 +1,4 @@
|
||||
{"step": 1, "timestamp": "2026-06-06T12:00:00.600000+05:30", "hierarchy": {"elements": [{"resourceId": "LoginScreen", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginScreen", "class": "android.view.View", "text": ""}}, {"resourceId": "LoginEmail", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginEmail", "class": "android.widget.EditText", "text": ""}}, {"resourceId": "LoginPassword", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginPassword", "class": "android.widget.EditText", "text": ""}}, {"resourceId": "LoginSubmit", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginSubmit", "class": "android.view.View", "text": ""}}]}, "next_action": {"kind": "InputText", "text": "[email protected]", "selector": "testTag:LoginScreen > testTag:LoginEmail"}}
|
||||
{"step": 2, "timestamp": "2026-06-06T12:00:01.200000+05:30", "hierarchy": {"elements": [{"resourceId": "LoginScreen", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginScreen", "class": "android.view.View", "text": ""}}, {"resourceId": "LoginEmail", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginEmail", "class": "android.widget.EditText", "text": "[email protected]"}}, {"resourceId": "LoginPassword", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginPassword", "class": "android.widget.EditText", "text": ""}}, {"resourceId": "LoginSubmit", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginSubmit", "class": "android.view.View", "text": ""}}]}, "next_action": {"kind": "InputText", "text": "ledger123", "selector": "testTag:LoginScreen > testTag:LoginPassword"}}
|
||||
{"step": 3, "timestamp": "2026-06-06T12:00:01.800000+05:30", "hierarchy": {"elements": [{"resourceId": "LoginScreen", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginScreen", "class": "android.view.View", "text": ""}}, {"resourceId": "LoginEmail", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginEmail", "class": "android.widget.EditText", "text": "[email protected]"}}, {"resourceId": "LoginPassword", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginPassword", "class": "android.widget.EditText", "text": "ledger123"}}, {"resourceId": "LoginSubmit", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginSubmit", "class": "android.view.View", "text": ""}}]}}
|
||||
{"step": 4, "timestamp": "2026-06-06T12:00:02.400000+05:30", "hierarchy": {"elements": [{"resourceId": "LoginScreen", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginScreen", "class": "android.view.View", "text": ""}}, {"resourceId": "LoginEmail", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginEmail", "class": "android.widget.EditText", "text": "[email protected]"}}, {"resourceId": "LoginPassword", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginPassword", "class": "android.widget.EditText", "text": "ledger123"}}, {"resourceId": "LoginSubmit", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginSubmit", "class": "android.view.View", "text": ""}}]}}
|
||||
@@ -0,0 +1 @@
|
||||
0
|
||||
@@ -0,0 +1,5 @@
|
||||
companion pid=4242 listening on 127.0.0.1:51000
|
||||
objc[4242]: Class FBSDKError is implemented in both /a (0x1) and /b (0x2). One of the two will be used. Which one is undefined.
|
||||
WARNING: All log messages before absl::InitializeLog() is called are written to STDERR
|
||||
archived previous runs under runs/ERRORS_archive
|
||||
run complete
|
||||
@@ -0,0 +1,4 @@
|
||||
{"step": 1, "timestamp": "2026-06-06T12:00:00.600000+05:30", "hierarchy": {"elements": [{"resourceId": "LoginScreen", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginScreen", "class": "android.view.View", "text": ""}}, {"resourceId": "LoginEmail", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginEmail", "class": "android.widget.EditText", "text": ""}}, {"resourceId": "LoginPassword", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginPassword", "class": "android.widget.EditText", "text": ""}}, {"resourceId": "LoginSubmit", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginSubmit", "class": "android.view.View", "text": ""}}]}, "next_action": {"kind": "InputText", "text": "[email protected]", "selector": "testTag:LoginScreen > testTag:LoginEmail"}}
|
||||
{"step": 2, "timestamp": "2026-06-06T12:00:01.200000+05:30", "hierarchy": {"elements": [{"resourceId": "LoginScreen", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginScreen", "class": "android.view.View", "text": ""}}, {"resourceId": "LoginEmail", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginEmail", "class": "android.widget.EditText", "text": "[email protected]"}}, {"resourceId": "LoginPassword", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginPassword", "class": "android.widget.EditText", "text": ""}}, {"resourceId": "LoginSubmit", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginSubmit", "class": "android.view.View", "text": ""}}]}, "next_action": {"kind": "InputText", "text": "ledger123", "selector": "testTag:LoginScreen > testTag:LoginPassword"}}
|
||||
{"step": 3, "timestamp": "2026-06-06T12:00:01.800000+05:30", "hierarchy": {"elements": [{"resourceId": "LoginScreen", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginScreen", "class": "android.view.View", "text": ""}}, {"resourceId": "LoginEmail", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginEmail", "class": "android.widget.EditText", "text": "[email protected]"}}, {"resourceId": "LoginPassword", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginPassword", "class": "android.widget.EditText", "text": "ledger123"}}, {"resourceId": "LoginSubmit", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginSubmit", "class": "android.view.View", "text": ""}}]}}
|
||||
{"step": 4, "timestamp": "2026-06-06T12:00:02.400000+05:30", "hierarchy": {"elements": [{"resourceId": "LoginScreen", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginScreen", "class": "android.view.View", "text": ""}}, {"resourceId": "LoginEmail", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginEmail", "class": "android.widget.EditText", "text": "[email protected]"}}, {"resourceId": "LoginPassword", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginPassword", "class": "android.widget.EditText", "text": "ledger123"}}, {"resourceId": "LoginSubmit", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginSubmit", "class": "android.view.View", "text": ""}}]}}
|
||||
@@ -0,0 +1 @@
|
||||
0
|
||||
@@ -0,0 +1,5 @@
|
||||
companion pid=4242 listening on 127.0.0.1:51000
|
||||
objc[4242]: Class FBSDKError is implemented in both /a (0x1) and /b (0x2). One of the two will be used. Which one is undefined.
|
||||
WARNING: All log messages before absl::InitializeLog() is called are written to STDERR
|
||||
archived previous runs under runs/ERRORS_archive
|
||||
error: hierarchy fetch failed: companion connection lost
|
||||
@@ -0,0 +1,4 @@
|
||||
{"step": 1, "timestamp": "2026-06-06T12:00:00.600000+05:30", "hierarchy": {"elements": [{"resourceId": "LoginScreen", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginScreen", "class": "android.view.View", "text": ""}}, {"resourceId": "LoginEmail", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginEmail", "class": "android.widget.EditText", "text": ""}}, {"resourceId": "LoginPassword", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginPassword", "class": "android.widget.EditText", "text": ""}}, {"resourceId": "LoginSubmit", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginSubmit", "class": "android.view.View", "text": ""}}]}, "next_action": {"kind": "InputText", "text": "[email protected]", "selector": "testTag:LoginScreen > testTag:LoginEmail"}}
|
||||
{"step": 2, "timestamp": "2026-06-06T12:00:01.200000+05:30", "hierarchy": {"elements": [{"resourceId": "LoginScreen", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginScreen", "class": "android.view.View", "text": ""}}, {"resourceId": "LoginEmail", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginEmail", "class": "android.widget.EditText", "text": "[email protected]"}}, {"resourceId": "LoginPassword", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginPassword", "class": "android.widget.EditText", "text": ""}}, {"resourceId": "LoginSubmit", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginSubmit", "class": "android.view.View", "text": ""}}]}, "next_action": {"kind": "InputText", "text": "ledger123", "selector": "testTag:LoginScreen > testTag:LoginPassword"}}
|
||||
{"step": 3, "timestamp": "2026-06-06T12:00:01.800000+05:30", "hierarchy": {"elements": [{"resourceId": "LoginScreen", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginScreen", "class": "android.view.View", "text": ""}}, {"resourceId": "LoginEmail", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginEmail", "class": "android.widget.EditText", "text": "[email protected]"}}, {"resourceId": "LoginPassword", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginPassword", "class": "android.widget.EditText", "text": "ledger123"}}, {"resourceId": "LoginSubmit", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginSubmit", "class": "android.view.View", "text": ""}}]}}
|
||||
{"step": 4, "timestamp": "2026-06-06T12:00:02.400000+05:30", "hierarchy": {"elements": [{"resourceId": "LoginScreen", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginScreen", "class": "android.view.View", "text": ""}}, {"resourceId": "LoginEmail", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginEmail", "class": "android.widget.EditText", "text": "[email protected]"}}, {"resourceId": "LoginPassword", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginPassword", "class": "android.widget.EditText", "text": "ledger123"}}, {"resourceId": "LoginSubmit", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginSubmit", "class": "android.view.View", "text": ""}}]}}
|
||||
@@ -0,0 +1 @@
|
||||
0
|
||||
@@ -0,0 +1 @@
|
||||
run complete
|
||||
@@ -0,0 +1,2 @@
|
||||
{"step": 1, "timestamp": "2026-06-06T12:00:00.600000+05:30", "hierarchy": {"elements": [{"resourceId": "LoginScreen", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginScreen", "class": "android.view.View", "text": ""}}, {"resourceId": "LoginEmail", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginEmail", "class": "android.widget.EditText", "text": "[email protected]"}}, {"resourceId": "LoginPassword", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginPassword", "class": "android.widget.EditText", "text": "leftover"}}, {"resourceId": "LoginSubmit", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginSubmit", "class": "android.view.View", "text": ""}}]}, "next_action": {"kind": "InputText", "text": "[email protected]", "selector": "testTag:LoginScreen > testTag:LoginEmail"}}
|
||||
{"step": 2, "timestamp": "2026-06-06T12:00:01.200000+05:30", "hierarchy": {"elements": [{"resourceId": "LoginScreen", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginScreen", "class": "android.view.View", "text": ""}}, {"resourceId": "LoginEmail", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginEmail", "class": "android.widget.EditText", "text": "[email protected]"}}, {"resourceId": "LoginPassword", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginPassword", "class": "android.widget.EditText", "text": "leftover"}}, {"resourceId": "LoginSubmit", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginSubmit", "class": "android.view.View", "text": ""}}]}}
|
||||
@@ -0,0 +1 @@
|
||||
0
|
||||
@@ -0,0 +1 @@
|
||||
run complete
|
||||
@@ -0,0 +1,2 @@
|
||||
{"step": 1, "timestamp": "2026-06-06T12:00:00.600000+05:30", "hierarchy": {"elements": [{"resourceId": "LoginScreen", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginScreen", "class": "android.view.View", "text": ""}}, {"resourceId": "LoginEmail", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginEmail", "class": "android.widget.EditText", "text": ""}}, {"resourceId": "LoginPassword", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginPassword", "class": "android.widget.EditText", "text": ""}}, {"resourceId": "LoginSubmit", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginSubmit", "class": "android.view.View", "text": ""}}]}, "next_action": {"kind": "InputText", "text": "[email protected]", "selector": "testTag:LoginScreen > testTag:LoginEmail"}}
|
||||
{"step": 2, "timestamp": "2026-06-06T12:00:01.200000+05:30", "hierarchy": {"elements": [{"resourceId": "LoginScreen", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginScreen", "class": "android.view.View", "text": ""}}, {"resourceId": "LoginEmail", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginEmail", "class": "android.widget.EditText", "text": "[email protected]@folio.app"}}, {"resourceId": "LoginPassword", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginPassword", "class": "android.widget.EditText", "text": ""}}, {"resourceId": "LoginSubmit", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginSubmit", "class": "android.view.View", "text": ""}}]}}
|
||||
@@ -0,0 +1 @@
|
||||
0
|
||||
+1
@@ -0,0 +1 @@
|
||||
run complete
|
||||
@@ -0,0 +1,4 @@
|
||||
{"step": 1, "timestamp": "2026-06-06T12:00:04+05:30", "hierarchy": {"elements": [{"resourceId": "LoginScreen", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginScreen", "class": "android.view.View", "text": ""}}, {"resourceId": "LoginEmail", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginEmail", "class": "android.widget.EditText", "text": ""}}, {"resourceId": "LoginPassword", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginPassword", "class": "android.widget.EditText", "text": ""}}, {"resourceId": "LoginSubmit", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginSubmit", "class": "android.view.View", "text": ""}}]}, "next_action": {"kind": "InputText", "text": "[email protected]", "selector": "testTag:LoginScreen > testTag:LoginEmail"}}
|
||||
{"step": 2, "timestamp": "2026-06-06T12:00:08+05:30", "hierarchy": {"elements": [{"resourceId": "LoginScreen", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginScreen", "class": "android.view.View", "text": ""}}, {"resourceId": "LoginEmail", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginEmail", "class": "android.widget.EditText", "text": "[email protected]"}}, {"resourceId": "LoginPassword", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginPassword", "class": "android.widget.EditText", "text": ""}}, {"resourceId": "LoginSubmit", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginSubmit", "class": "android.view.View", "text": ""}}]}, "next_action": {"kind": "InputText", "text": "ledger123", "selector": "testTag:LoginScreen > testTag:LoginPassword"}}
|
||||
{"step": 3, "timestamp": "2026-06-06T12:00:12+05:30", "hierarchy": {"elements": [{"resourceId": "LoginScreen", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginScreen", "class": "android.view.View", "text": ""}}, {"resourceId": "LoginEmail", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginEmail", "class": "android.widget.EditText", "text": "[email protected]"}}, {"resourceId": "LoginPassword", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginPassword", "class": "android.widget.EditText", "text": "ledger123"}}, {"resourceId": "LoginSubmit", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginSubmit", "class": "android.view.View", "text": ""}}]}}
|
||||
{"step": 4, "timestamp": "2026-06-06T12:00:16+05:30", "hierarchy": {"elements": [{"resourceId": "LoginScreen", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginScreen", "class": "android.view.View", "text": ""}}, {"resourceId": "LoginEmail", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginEmail", "class": "android.widget.EditText", "text": "[email protected]"}}, {"resourceId": "LoginPassword", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginPassword", "class": "android.widget.EditText", "text": "ledger123"}}, {"resourceId": "LoginSubmit", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginSubmit", "class": "android.view.View", "text": ""}}]}}
|
||||
Vendored
+121
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env python3
|
||||
# Regenerates the canned gate-analyzer fixtures. Each fixture is a minimal but
|
||||
# format-accurate run directory (trace.jsonl + output.log + exit_status) that
|
||||
# isolates one gate's pass or fail condition. Run from this directory:
|
||||
# python3 generate.py
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
BASE = datetime(2026, 6, 6, 12, 0, 0, tzinfo=timezone(timedelta(hours=5, minutes=30)))
|
||||
|
||||
|
||||
def element(resource_id, text="", element_class="android.widget.EditText"):
|
||||
return {
|
||||
"resourceId": resource_id,
|
||||
"class": element_class,
|
||||
"editable": True,
|
||||
"bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135},
|
||||
"attrs": {"resource-id": resource_id, "class": element_class, "text": text},
|
||||
}
|
||||
|
||||
|
||||
def login_screen(email_text="", password_text=""):
|
||||
return {
|
||||
"elements": [
|
||||
element("LoginScreen", element_class="android.view.View"),
|
||||
element("LoginEmail", email_text),
|
||||
element("LoginPassword", password_text),
|
||||
element("LoginSubmit", element_class="android.view.View"),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def input_action(field, text):
|
||||
return {
|
||||
"kind": "InputText",
|
||||
"text": text,
|
||||
"selector": f"testTag:LoginScreen > testTag:{field}",
|
||||
}
|
||||
|
||||
|
||||
def step(index, gap_ms, hierarchy, next_action=None):
|
||||
timestamp = (BASE + timedelta(milliseconds=index * gap_ms)).isoformat()
|
||||
record = {"step": index, "timestamp": timestamp, "hierarchy": hierarchy}
|
||||
if next_action is not None:
|
||||
record["next_action"] = next_action
|
||||
return record
|
||||
|
||||
|
||||
def write_run(name, steps, exit_status="0", output_lines=None):
|
||||
directory = os.path.join(HERE, name)
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
with open(os.path.join(directory, "trace.jsonl"), "w") as handle:
|
||||
for record in steps:
|
||||
handle.write(json.dumps(record) + "\n")
|
||||
with open(os.path.join(directory, "exit_status"), "w") as handle:
|
||||
handle.write(exit_status)
|
||||
lines = output_lines if output_lines is not None else ["run complete\n"]
|
||||
with open(os.path.join(directory, "output.log"), "w") as handle:
|
||||
handle.writelines(lines)
|
||||
|
||||
|
||||
# A clean run: empty login fields first, single (not doubled) typed values in
|
||||
# the following snapshots, sub-second step gaps.
|
||||
def healthy_steps(gap_ms=600):
|
||||
return [
|
||||
step(1, gap_ms, login_screen("", ""), input_action("LoginEmail", "[email protected]")),
|
||||
step(2, gap_ms, login_screen("[email protected]", ""), input_action("LoginPassword", "ledger123")),
|
||||
step(3, gap_ms, login_screen("[email protected]", "ledger123")),
|
||||
step(4, gap_ms, login_screen("[email protected]", "ledger123")),
|
||||
]
|
||||
|
||||
|
||||
BENIGN_NOISE = [
|
||||
"companion pid=4242 listening on 127.0.0.1:51000\n",
|
||||
"objc[4242]: Class FBSDKError is implemented in both /a (0x1) and /b (0x2). "
|
||||
"One of the two will be used. Which one is undefined.\n",
|
||||
"WARNING: All log messages before absl::InitializeLog() is called are written to STDERR\n",
|
||||
# An incidental ERROR substring inside a longer word: the G2 scan is
|
||||
# word-bounded, so this must not trip the gate.
|
||||
"archived previous runs under runs/ERRORS_archive\n",
|
||||
]
|
||||
|
||||
write_run("pass", healthy_steps())
|
||||
|
||||
write_run("g1-nonzero-exit", healthy_steps(), exit_status="1")
|
||||
|
||||
write_run("g2-benign-only", healthy_steps(), output_lines=BENIGN_NOISE + ["run complete\n"])
|
||||
|
||||
write_run(
|
||||
"g2-real-error",
|
||||
healthy_steps(),
|
||||
output_lines=BENIGN_NOISE
|
||||
+ ["error: hierarchy fetch failed: companion connection lost\n"],
|
||||
)
|
||||
|
||||
# G3: first snapshot already carries text in the login fields (clear-state
|
||||
# failed to wipe the previous session).
|
||||
write_run(
|
||||
"g3-dirty-field",
|
||||
[
|
||||
step(1, 600, login_screen("[email protected]", "leftover"), input_action("LoginEmail", "[email protected]")),
|
||||
step(2, 600, login_screen("[email protected]", "leftover")),
|
||||
],
|
||||
)
|
||||
|
||||
# G4: after typing into LoginEmail the next snapshot shows the value appended to
|
||||
# itself (append-vs-replace / double-paste regression).
|
||||
write_run(
|
||||
"g4-doubled-text",
|
||||
[
|
||||
step(1, 600, login_screen("", ""), input_action("LoginEmail", "[email protected]")),
|
||||
step(2, 600, login_screen("[email protected]@folio.app", "")),
|
||||
],
|
||||
)
|
||||
|
||||
# G5: step gaps far exceed the 2.5s ceiling, driving p95 over the limit.
|
||||
write_run("g5-slow-p95", healthy_steps(gap_ms=4000))
|
||||
|
||||
print("fixtures written")
|
||||
+1
@@ -0,0 +1 @@
|
||||
0
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
run complete
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
{"step": 1, "timestamp": "2026-06-06T12:00:00.600000+05:30", "hierarchy": {"elements": [{"resourceId": "LoginScreen", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginScreen", "class": "android.view.View", "text": ""}}, {"resourceId": "LoginEmail", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginEmail", "class": "android.widget.EditText", "text": ""}}, {"resourceId": "LoginPassword", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginPassword", "class": "android.widget.EditText", "text": ""}}, {"resourceId": "LoginSubmit", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginSubmit", "class": "android.view.View", "text": ""}}]}, "next_action": {"kind": "InputText", "text": "[email protected]", "selector": "testTag:LoginScreen > testTag:LoginEmail"}}
|
||||
{"step": 2, "timestamp": "2026-06-06T12:00:01.200000+05:30", "hierarchy": {"elements": [{"resourceId": "LoginScreen", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginScreen", "class": "android.view.View", "text": ""}}, {"resourceId": "LoginEmail", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginEmail", "class": "android.widget.EditText", "text": "[email protected]"}}, {"resourceId": "LoginPassword", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginPassword", "class": "android.widget.EditText", "text": ""}}, {"resourceId": "LoginSubmit", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginSubmit", "class": "android.view.View", "text": ""}}]}, "next_action": {"kind": "InputText", "text": "ledger123", "selector": "testTag:LoginScreen > testTag:LoginPassword"}}
|
||||
{"step": 3, "timestamp": "2026-06-06T12:00:01.800000+05:30", "hierarchy": {"elements": [{"resourceId": "LoginScreen", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginScreen", "class": "android.view.View", "text": ""}}, {"resourceId": "LoginEmail", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginEmail", "class": "android.widget.EditText", "text": "[email protected]"}}, {"resourceId": "LoginPassword", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginPassword", "class": "android.widget.EditText", "text": "ledger123"}}, {"resourceId": "LoginSubmit", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginSubmit", "class": "android.view.View", "text": ""}}]}}
|
||||
{"step": 4, "timestamp": "2026-06-06T12:00:02.400000+05:30", "hierarchy": {"elements": [{"resourceId": "LoginScreen", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginScreen", "class": "android.view.View", "text": ""}}, {"resourceId": "LoginEmail", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginEmail", "class": "android.widget.EditText", "text": "[email protected]"}}, {"resourceId": "LoginPassword", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginPassword", "class": "android.widget.EditText", "text": "ledger123"}}, {"resourceId": "LoginSubmit", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginSubmit", "class": "android.view.View", "text": ""}}]}}
|
||||
+6
-4
@@ -19,11 +19,12 @@ Run a spec against an app for a fixed duration.
|
||||
| `--launcher-activity` | resolved | Optional `<pkg>/<activity>` to launch. Overrides default resolution. |
|
||||
| `--platform` | `android` | Target platform: `android`, `ios`, or `web`. |
|
||||
| `--avd` | optional (android) | Android AVD name to boot if no device is connected. Required only when no device is connected and multiple AVDs exist. |
|
||||
| `--ios-device` | optional (ios) | iOS simulator name or UDID to boot if none is running. |
|
||||
| `--ios-device` | optional (ios) | iOS target: a simulator name/UDID to boot, or a connected device's name, UDID, or CoreDevice id. |
|
||||
| `--ios-app-path` | optional (ios) | Path to the `.app` bundle for clear-state reinstall (simulator via `simctl`, device via `devicectl`). |
|
||||
| `--duration` | `5m` | Total test duration (`30s`, `5m`, `2h`, `1d`). |
|
||||
| `--seed` | `0` | PRNG seed. `0` uses a random seed and records it in `meta.json`. |
|
||||
| `--output` | `./runs` | Output directory for traces. |
|
||||
| `--clear-data` | `false` | Clear app data before launching so the run starts from a fresh install. |
|
||||
| `--clear-data` | `true` | Clear app data before launching so the run starts from a fresh install. Pass `--clear-data=false` to resume prior state. |
|
||||
|
||||
## `sanderling replay [run-or-runs-dir]`
|
||||
|
||||
@@ -42,7 +43,7 @@ See [the replay UI page](../replay/) for the panel reference and keyboard shortc
|
||||
Check the host environment for a working sanderling setup.
|
||||
|
||||
```
|
||||
sanderling doctor [--platform web|android|ios|all]
|
||||
sanderling doctor [--platform web|android|ios|ios-device|all]
|
||||
```
|
||||
|
||||
`--platform` defaults to `all`, which runs every platform's checks (deduped). Pass a specific platform to scope the output.
|
||||
@@ -51,7 +52,8 @@ sanderling doctor [--platform web|android|ios|all]
|
||||
|---|---|
|
||||
| `web` | headless Chromium can launch (the bundled CDP surface boots a real browser). |
|
||||
| `android` | `adb` on PATH; `emulator` on PATH or under `ANDROID_HOME`; Java 17+; embedded native sidecar JAR is real. |
|
||||
| `ios` | `xcrun` on PATH; `simctl` on PATH; Java 17+; embedded native sidecar JAR is real. |
|
||||
| `ios` | `xcrun` on PATH; `simctl` on PATH. The simulator path drives the native companion with no JVM. |
|
||||
| `ios-device` | the `ios` checks plus `devicectl`; the macOS `usbmuxd` socket; a connected, paired device; App Store Connect signing credentials present. |
|
||||
|
||||
## `sanderling version`
|
||||
|
||||
|
||||
@@ -87,6 +87,21 @@ IOS_DEVICE="iPhone 15" just test-ios # pick a different simulator
|
||||
|
||||
`just test-ios` boots the simulator if needed, builds and installs the app, then runs `sanderling test --platform ios`.
|
||||
|
||||
#### Physical device
|
||||
|
||||
A connected iPhone is driven over a usbmux tunnel by a runner the driver builds and signs at run time. The tunnel talks to macOS's own `usbmuxd`, so nothing extra is installed beyond Xcode. It needs App Store Connect signing credentials in the environment (a gitignored `.env` is loaded by `just`):
|
||||
|
||||
```sh
|
||||
SANDERLING_IOS_TEAM=<10-char team id>
|
||||
ASC_API_KEY_ID=<key id>
|
||||
ASC_API_ISSUER_ID=<issuer id>
|
||||
ASC_API_KEY_PATH=<absolute path to AuthKey_*.p8>
|
||||
|
||||
IOS_DEVICE="iPhone" just test-ios-device # name, UDID, or CoreDevice id
|
||||
```
|
||||
|
||||
Run `sanderling doctor --platform ios-device` to check `devicectl`, the `usbmuxd` socket, a connected and paired device, and the signing credentials before a run.
|
||||
|
||||
## What you get
|
||||
|
||||
When the run ends, the trace lands in `sanderling/runs/<timestamp>/`:
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application
|
||||
android:label="Folio"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:allowBackup="false"
|
||||
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
||||
<activity
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 709 B |
Binary file not shown.
|
After Width: | Height: | Size: 563 B |
Binary file not shown.
|
After Width: | Height: | Size: 924 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "icon-1024.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -19,25 +19,33 @@ targets:
|
||||
sources:
|
||||
- iosApp
|
||||
dependencies:
|
||||
- framework: ../shared/build/bin/iosSimulatorArm64/debugFramework/Shared.framework
|
||||
embed: false
|
||||
- sdk: libsqlite3.tbd
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: app.folio
|
||||
PRODUCT_NAME: iosApp
|
||||
INFOPLIST_FILE: iosApp/Info.plist
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
# The Kotlin framework slice differs by SDK: device builds link the
|
||||
# iosArm64 framework, simulator builds the iosSimulatorArm64 one. The
|
||||
# search path and the prebuild gradle task both follow this variable.
|
||||
KOTLIN_FRAMEWORK_DIR[sdk=iphoneos*]: iosArm64
|
||||
KOTLIN_FRAMEWORK_DIR[sdk=iphonesimulator*]: iosSimulatorArm64
|
||||
FRAMEWORK_SEARCH_PATHS:
|
||||
- $(SRCROOT)/../shared/build/bin/$(KOTLIN_FRAMEWORK_DIR)/debugFramework
|
||||
OTHER_LDFLAGS:
|
||||
- -ObjC
|
||||
KOTLIN_FRAMEWORK_DIR: iosSimulatorArm64
|
||||
- -framework
|
||||
- Shared
|
||||
preBuildScripts:
|
||||
- name: Build Kotlin framework
|
||||
script: |
|
||||
cd "$SRCROOT/../.."
|
||||
if [[ "$PLATFORM_NAME" == iphoneos ]]; then
|
||||
task=linkDebugFrameworkIosArm64
|
||||
else
|
||||
task=linkDebugFrameworkIosSimulatorArm64
|
||||
fi
|
||||
ANDROID_HOME="${ANDROID_HOME:-$HOME/Library/Android/sdk}" \
|
||||
./gradlew :app:shared:linkDebugFrameworkIosSimulatorArm64
|
||||
outputFiles:
|
||||
- $(SRCROOT)/../shared/build/bin/iosSimulatorArm64/debugFramework/Shared.framework/Shared
|
||||
./gradlew :app:shared:$task
|
||||
basedOnDependencyAnalysis: false
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>Folio</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'><rect width='64' height='64' fill='black'/><circle cx='32' cy='32' r='14' fill='white'/></svg>">
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; height: 100%; background: #000; color: #fff; font-family: system-ui, sans-serif; }
|
||||
#app { height: 100%; }
|
||||
|
||||
+40
-1
@@ -7,6 +7,8 @@ duration := env_var_or_default("DURATION", "1m")
|
||||
seed := env_var_or_default("SEED", "0")
|
||||
output := env_var_or_default("OUTPUT", justfile_directory() / "sanderling" / "runs")
|
||||
ios_device := env_var_or_default("IOS_DEVICE", "iPhone 17 Pro")
|
||||
ios_app := justfile_directory() / "app" / "iosApp" / "build" / "Build" / "Products" / "Debug-iphonesimulator" / "iosApp.app"
|
||||
ios_app_device := justfile_directory() / "app" / "iosApp" / "build" / "Build" / "Products" / "Debug-iphoneos" / "iosApp.app"
|
||||
|
||||
default:
|
||||
@just --list
|
||||
@@ -118,9 +120,28 @@ ios:
|
||||
-destination 'platform=iOS Simulator,name={{ios_device}}' \
|
||||
-derivedDataPath app/iosApp/build \
|
||||
build | tail -5
|
||||
xcrun simctl install booted app/iosApp/build/Build/Products/Debug-iphonesimulator/iosApp.app
|
||||
xcrun simctl install booted "{{ios_app}}"
|
||||
xcrun simctl launch booted app.folio
|
||||
|
||||
# Build the folio app for a connected physical iOS device (signed via .env creds).
|
||||
# Signing reads ASC_API_* and DEVELOPMENT_TEAM/SANDERLING_IOS_TEAM from .env;
|
||||
# sanderling installs the build on the device per run via devicectl.
|
||||
ios-device:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
export ANDROID_HOME="$(just _android-home)"
|
||||
just ios-gen
|
||||
xcodebuild -project app/iosApp/iosApp.xcodeproj -scheme iosApp \
|
||||
-destination 'generic/platform=iOS' \
|
||||
-derivedDataPath app/iosApp/build \
|
||||
-allowProvisioningUpdates \
|
||||
-authenticationKeyPath "$ASC_API_KEY_PATH" \
|
||||
-authenticationKeyID "$ASC_API_KEY_ID" \
|
||||
-authenticationKeyIssuerID "$ASC_API_ISSUER_ID" \
|
||||
CODE_SIGN_STYLE=Automatic \
|
||||
DEVELOPMENT_TEAM="${SANDERLING_IOS_TEAM:-${DEVELOPMENT_TEAM:-}}" \
|
||||
build | tail -5
|
||||
|
||||
# Run 'sanderling test' against the folio app. Uses a connected device if one is
|
||||
# online; otherwise boots AVD=<name> when provided, or auto-boots a bootable AVD.
|
||||
# Depends on install so the run always fuzzes the current build, matching
|
||||
@@ -167,7 +188,25 @@ test-ios:
|
||||
--platform ios \
|
||||
--spec "{{justfile_directory()}}/sanderling/spec.ts" \
|
||||
--bundle-id app.folio \
|
||||
--ios-app-path "{{ios_app}}" \
|
||||
"${ios_device_flag[@]}" \
|
||||
--duration "{{duration}}" \
|
||||
--seed "{{seed}}" \
|
||||
--output "{{output}}"
|
||||
|
||||
# Requires App Store Connect signing creds in .env and IOS_DEVICE set to the
|
||||
# connected device's name (or UDID / CoreDevice id).
|
||||
# Build + install + run sanderling spec on a connected physical iOS device.
|
||||
test-ios-device:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
just ios-device
|
||||
"{{sanderling}}" test \
|
||||
--platform ios \
|
||||
--spec "{{justfile_directory()}}/sanderling/spec.ts" \
|
||||
--bundle-id app.folio \
|
||||
--ios-app-path "{{ios_app_device}}" \
|
||||
--ios-device "{{ios_device}}" \
|
||||
--duration "{{duration}}" \
|
||||
--seed "{{seed}}" \
|
||||
--output "{{output}}"
|
||||
@@ -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
@@ -0,0 +1,219 @@
|
||||
// This file implements the physical-device mode of Driver. The device is driven
|
||||
// runner-only: the in-device XCUITest runner serves every capability over a
|
||||
// usbmux tunnel, with no legacy companion. The simulator hybrid path is left
|
||||
// byte-identical; device mode swaps three sim-only seams (reinstall, container
|
||||
// reset, paste grant) and brings the runner up over the tunnel instead of a
|
||||
// local listener.
|
||||
package ioscompanion
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/priyanshujain/sanderling/internal/driver/ioscompanion/transport"
|
||||
)
|
||||
|
||||
// DeviceOptions configures a device-mode Driver. Signing credentials are not
|
||||
// carried here: realSpawnDeviceRunner reads them from the environment at the
|
||||
// point of use so secrets never reach the Options struct or run artifacts.
|
||||
type DeviceOptions struct {
|
||||
// HardwareUDID feeds xcodebuild -destination and the usbmux device match.
|
||||
HardwareUDID string
|
||||
// CoreDeviceID feeds devicectl install/uninstall.
|
||||
CoreDeviceID string
|
||||
// BundleID is the app under test.
|
||||
BundleID string
|
||||
// AppPath is the .app bundle installed via devicectl for clear-state.
|
||||
AppPath string
|
||||
// Output receives the runner session log path and driver warnings.
|
||||
Output io.Writer
|
||||
// DoubleTapGapMilliseconds overrides the synthesized double-tap gap.
|
||||
DoubleTapGapMilliseconds float64
|
||||
|
||||
// Test seams. Production leaves them nil and NewDevice wires the real
|
||||
// build/spawn/tunnel/dial.
|
||||
spawnRunner func(ctx context.Context, address string) (*exec.Cmd, error)
|
||||
startTunnel func(ctx context.Context, hardwareUDID, localAddress, devicePort string) (io.Closer, error)
|
||||
dialRunner func(address string) (transport.Companion, error)
|
||||
pickAddress func() (string, error)
|
||||
}
|
||||
|
||||
// deviceStartupTimeout bounds the runner's startup once its hosting test
|
||||
// session is spawned and the tunnel is up. The session's cold start on a
|
||||
// physical device is slower than the simulator's, and the build that precedes
|
||||
// it runs outside this window (under the process context, not the startup one).
|
||||
const deviceStartupTimeout = 180 * time.Second
|
||||
|
||||
// NewDevice brings up a runner-only Driver against a physical device: it builds
|
||||
// and spawns the in-device runner, opens a usbmux tunnel to it, dials the runner
|
||||
// over the tunnel, health-probes it, and caches the screen dimensions. Call
|
||||
// Close when done to stop the runner session and the tunnel.
|
||||
func NewDevice(ctx context.Context, options DeviceOptions) (*Driver, error) {
|
||||
if options.HardwareUDID == "" {
|
||||
return nil, errors.New("ios device: HardwareUDID is required")
|
||||
}
|
||||
if options.CoreDeviceID == "" {
|
||||
return nil, errors.New("ios device: CoreDeviceID is required")
|
||||
}
|
||||
output := options.Output
|
||||
if output == nil {
|
||||
output = io.Discard
|
||||
}
|
||||
gap := options.DoubleTapGapMilliseconds
|
||||
if gap <= 0 {
|
||||
gap = DefaultDoubleTapGapMilliseconds
|
||||
}
|
||||
|
||||
d := &Driver{
|
||||
udid: options.HardwareUDID,
|
||||
coreDeviceID: options.CoreDeviceID,
|
||||
bundleID: options.BundleID,
|
||||
appPath: options.AppPath,
|
||||
output: output,
|
||||
doubleTapGapMilliseconds: gap,
|
||||
deviceMode: true,
|
||||
hybrid: false,
|
||||
spawnRunner: options.spawnRunner,
|
||||
startTunnel: options.startTunnel,
|
||||
}
|
||||
if d.spawnRunner == nil {
|
||||
d.spawnRunner = d.realSpawnDeviceRunner
|
||||
}
|
||||
if d.startTunnel == nil {
|
||||
d.startTunnel = startUsbmuxTunnel
|
||||
}
|
||||
d.dialRunner = options.dialRunner
|
||||
if d.dialRunner == nil {
|
||||
d.dialRunner = func(address string) (transport.Companion, error) {
|
||||
return transport.DialRunner(address, d.udid, d.bundleID)
|
||||
}
|
||||
}
|
||||
if options.pickAddress != nil {
|
||||
d.pickDeviceAddress = options.pickAddress
|
||||
} else {
|
||||
d.pickDeviceAddress = pickLoopbackAddress
|
||||
}
|
||||
|
||||
// Device seams: clear-state reinstalls via devicectl; the container reset and
|
||||
// paste grant are simulator-only and become no-ops. The runner types
|
||||
// natively, so no paste prompt is ever hit.
|
||||
d.reinstallApp = d.devicectlReinstall
|
||||
d.resetContainer = d.deviceResetContainerUnsupported
|
||||
d.grantPaste = func(context.Context) error { return nil }
|
||||
d.restart = d.respawnDevice
|
||||
d.processContext, d.processCancel = context.WithCancel(ctx)
|
||||
|
||||
if err := d.bringUpDevice(ctx); err != nil {
|
||||
d.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
description, err := d.companion.Describe(ctx)
|
||||
if err != nil {
|
||||
d.Close()
|
||||
return nil, fmt.Errorf("describe device: %w", err)
|
||||
}
|
||||
d.screenWidth = description.WidthPoints
|
||||
d.screenHeight = description.HeightPoints
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// bringUpDevice builds (if needed) and spawns the in-device runner, opens the
|
||||
// tunnel, waits for the forwarded listener, dials the runner, and confirms
|
||||
// health. The build runs inside spawnRunner under the process context, so the
|
||||
// startup timeout only bounds the post-spawn wait, not the build.
|
||||
func (d *Driver) bringUpDevice(ctx context.Context) error {
|
||||
address, err := d.pickDeviceAddress()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d.runnerAddress = address
|
||||
|
||||
// The runner listens on the device loopback at the same port number the host
|
||||
// tunnel forwards from, so one picked free port covers both ends.
|
||||
runnerChild, err := d.spawnRunner(d.processContext, address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("spawn device runner: %w", err)
|
||||
}
|
||||
d.runnerChild = runnerChild
|
||||
|
||||
// The forwarder listens on the host loopback port and bridges to the same
|
||||
// port number on the device, where the runner listens.
|
||||
tunnel, err := d.startTunnel(d.processContext, d.udid, address, port)
|
||||
if err != nil {
|
||||
d.stopRunnerChild()
|
||||
return fmt.Errorf("start tunnel: %w", err)
|
||||
}
|
||||
d.tunnel = tunnel
|
||||
|
||||
startupCtx, cancel := context.WithTimeout(ctx, deviceStartupTimeout)
|
||||
defer cancel()
|
||||
|
||||
if err := waitForListener(startupCtx, address); err != nil {
|
||||
d.stopTunnel()
|
||||
d.stopRunnerChild()
|
||||
return fmt.Errorf("device runner listener: %w", err)
|
||||
}
|
||||
|
||||
companion, err := d.dialRunner(address)
|
||||
if err != nil {
|
||||
d.stopTunnel()
|
||||
d.stopRunnerChild()
|
||||
return fmt.Errorf("dial device runner: %w", err)
|
||||
}
|
||||
d.companion = companion
|
||||
|
||||
if err := d.waitForHealth(startupCtx); err != nil {
|
||||
_ = companion.Close()
|
||||
d.stopTunnel()
|
||||
d.stopRunnerChild()
|
||||
return fmt.Errorf("device runner health: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// respawnDevice is the device-path supervision restart: it tears down the runner
|
||||
// transport, its hosting session, and the tunnel, then brings a fresh set up.
|
||||
// Both the session and the tunnel restart together because a dropped usbmux
|
||||
// connection can take either down.
|
||||
func (d *Driver) respawnDevice(ctx context.Context) error {
|
||||
if d.companion != nil {
|
||||
_ = d.companion.Close()
|
||||
}
|
||||
d.stopRunnerChild()
|
||||
d.stopTunnel()
|
||||
return d.bringUpDevice(ctx)
|
||||
}
|
||||
|
||||
// devicectlReinstall uninstalls then installs the app bundle via devicectl,
|
||||
// keyed on the CoreDevice id. App lifecycle stays with devicectl: the runner's
|
||||
// own install path is simulator-specific.
|
||||
func (d *Driver) devicectlReinstall(ctx context.Context) error {
|
||||
_ = exec.CommandContext(ctx, "xcrun", "devicectl", "device", "uninstall", "app", "--device", d.coreDeviceID, d.bundleID).Run()
|
||||
output, err := exec.CommandContext(ctx, "xcrun", "devicectl", "device", "install", "app", "--device", d.coreDeviceID, d.appPath).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("devicectl install: %w: %s", err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// deviceResetContainerUnsupported warns once that device clear-state needs an
|
||||
// app path for a devicectl reinstall: there is no simulator-style data-container
|
||||
// wipe on a physical device.
|
||||
func (d *Driver) deviceResetContainerUnsupported(context.Context) error {
|
||||
if !d.clearStateWarned {
|
||||
fmt.Fprintln(d.output, "clear-state on a physical device requires --ios-app-path for a reinstall; skipping (state not cleared)")
|
||||
d.clearStateWarned = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package ioscompanion
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"os/exec"
|
||||
"testing"
|
||||
|
||||
"github.com/priyanshujain/sanderling/internal/driver/ioscompanion/transport"
|
||||
)
|
||||
|
||||
// startLoopbackListener accepts and immediately closes connections so
|
||||
// waitForListener succeeds against a real address without a real runner.
|
||||
func startLoopbackListener(t *testing.T) string {
|
||||
t.Helper()
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { listener.Close() })
|
||||
go func() {
|
||||
for {
|
||||
conn, acceptErr := listener.Accept()
|
||||
if acceptErr != nil {
|
||||
return
|
||||
}
|
||||
_ = conn.Close()
|
||||
}
|
||||
}()
|
||||
return listener.Addr().String()
|
||||
}
|
||||
|
||||
func testDeviceOptions(address string, companion transport.Companion) DeviceOptions {
|
||||
return DeviceOptions{
|
||||
HardwareUDID: "00008140-HW",
|
||||
CoreDeviceID: "CORE-1",
|
||||
BundleID: "com.example.app",
|
||||
Output: &bytes.Buffer{},
|
||||
spawnRunner: func(context.Context, string) (*exec.Cmd, error) { return &exec.Cmd{}, nil },
|
||||
startTunnel: func(context.Context, string, string, string) (io.Closer, error) { return io.NopCloser(nil), nil },
|
||||
dialRunner: func(string) (transport.Companion, error) { return companion, nil },
|
||||
pickAddress: func() (string, error) { return address, nil },
|
||||
}
|
||||
}
|
||||
|
||||
func newDeviceCompanion() *fakeTextEditingCompanion {
|
||||
companion := &fakeTextEditingCompanion{}
|
||||
companion.accessibilityJSON = "[]"
|
||||
companion.describe = transport.ScreenDescription{WidthPoints: 393, HeightPoints: 852}
|
||||
return companion
|
||||
}
|
||||
|
||||
func TestNewDeviceWiresRunnerOnlyMode(t *testing.T) {
|
||||
address := startLoopbackListener(t)
|
||||
companion := newDeviceCompanion()
|
||||
d, err := NewDevice(context.Background(), testDeviceOptions(address, companion))
|
||||
if err != nil {
|
||||
t.Fatalf("NewDevice: %v", err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
if !d.deviceMode {
|
||||
t.Fatal("deviceMode must be true")
|
||||
}
|
||||
if d.hybrid {
|
||||
t.Fatal("device mode must not be hybrid")
|
||||
}
|
||||
if d.runnerClient != nil {
|
||||
t.Fatal("device mode must leave runnerClient nil so InputText avoids the hybrid HID chord")
|
||||
}
|
||||
if d.companion != companion {
|
||||
t.Fatal("d.companion must be the runner dialed over the tunnel")
|
||||
}
|
||||
if d.coreDeviceID != "CORE-1" {
|
||||
t.Fatalf("coreDeviceID = %q, want CORE-1", d.coreDeviceID)
|
||||
}
|
||||
if d.screenWidth != 393 || d.screenHeight != 852 {
|
||||
t.Fatalf("screen = %dx%d, want 393x852", d.screenWidth, d.screenHeight)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDeviceRequiresIdentifiers(t *testing.T) {
|
||||
if _, err := NewDevice(context.Background(), DeviceOptions{CoreDeviceID: "x"}); err == nil {
|
||||
t.Fatal("missing HardwareUDID must error")
|
||||
}
|
||||
if _, err := NewDevice(context.Background(), DeviceOptions{HardwareUDID: "x"}); err == nil {
|
||||
t.Fatal("missing CoreDeviceID must error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceInputTextUsesNativeEditorNotKeyboardHID(t *testing.T) {
|
||||
address := startLoopbackListener(t)
|
||||
companion := newDeviceCompanion()
|
||||
d, err := NewDevice(context.Background(), testDeviceOptions(address, companion))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
if err := d.InputText(context.Background(), "héllo 🌟"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(companion.inputTexts) != 1 || companion.inputTexts[0] != "héllo 🌟" {
|
||||
t.Fatalf("inputTexts = %v, want native replace of the text", companion.inputTexts)
|
||||
}
|
||||
for _, call := range companion.recorded() {
|
||||
if call == "hid" {
|
||||
t.Fatal("device text must go through the native editor, never a keyboard HID chord")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceGesturesRouteTouchHIDToRunner(t *testing.T) {
|
||||
address := startLoopbackListener(t)
|
||||
companion := newDeviceCompanion()
|
||||
d, err := NewDevice(context.Background(), testDeviceOptions(address, companion))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
if err := d.DoubleTap(context.Background(), 10, 20); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if indexOf(companion.recorded(), "hid") < 0 {
|
||||
t.Fatalf("gesture must send touch HID to the runner; got %v", companion.recorded())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceEraseAndPressKeyRouteThroughEditor(t *testing.T) {
|
||||
address := startLoopbackListener(t)
|
||||
companion := newDeviceCompanion()
|
||||
d, err := NewDevice(context.Background(), testDeviceOptions(address, companion))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
if err := d.EraseText(context.Background(), 4); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.PressKey(context.Background(), "enter"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(companion.eraseCounts) != 1 || companion.eraseCounts[0] != 4 {
|
||||
t.Fatalf("eraseCounts = %v, want [4]", companion.eraseCounts)
|
||||
}
|
||||
if len(companion.pressedKeys) != 1 || companion.pressedKeys[0] != "enter" {
|
||||
t.Fatalf("pressedKeys = %v, want [enter]", companion.pressedKeys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceClearStateWithoutAppPathWarnsOnce(t *testing.T) {
|
||||
output := &bytes.Buffer{}
|
||||
d := &Driver{output: output, deviceMode: true}
|
||||
d.resetContainer = d.deviceResetContainerUnsupported
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := d.deviceResetContainerUnsupported(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if got := bytes.Count(output.Bytes(), []byte("requires --ios-app-path")); got != 1 {
|
||||
t.Fatalf("warning emitted %d times, want once", got)
|
||||
}
|
||||
}
|
||||
|
||||
type recordingCloser struct{ closed bool }
|
||||
|
||||
func (c *recordingCloser) Close() error {
|
||||
c.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestDeviceCloseStopsRunnerAndTunnel(t *testing.T) {
|
||||
d := &Driver{output: &bytes.Buffer{}}
|
||||
runner := exec.Command("sleep", "30")
|
||||
if err := runner.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tunnel := &recordingCloser{}
|
||||
d.runnerChild = runner
|
||||
d.tunnel = tunnel
|
||||
|
||||
d.Close()
|
||||
|
||||
if d.runnerChild != nil || d.tunnel != nil {
|
||||
t.Fatal("Close must clear the runner child and the tunnel")
|
||||
}
|
||||
if runner.ProcessState == nil {
|
||||
t.Fatal("Close must reap the runner session child")
|
||||
}
|
||||
if !tunnel.closed {
|
||||
t.Fatal("Close must close the usbmux tunnel")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
package ioscompanion
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// These env vars name the signing inputs so the account-specific team id is
|
||||
// never committed. The App Store Connect API key path/id/issuer come from the
|
||||
// same source. They back the no-Xcode-UI signing path.
|
||||
const (
|
||||
envTeam = "SANDERLING_IOS_TEAM"
|
||||
envTeamFallback = "DEVELOPMENT_TEAM"
|
||||
envAuthKeyPath = "ASC_API_KEY_PATH"
|
||||
envAuthKeyID = "ASC_API_KEY_ID"
|
||||
envAuthIssuer = "ASC_API_ISSUER_ID"
|
||||
envCompanionDir = "SANDERLING_COMPANION_DIR"
|
||||
)
|
||||
|
||||
// deviceRunnerScheme and deviceRunnerProject mirror companion/project.yml. The
|
||||
// build product is the runner whose xctestrun the test session consumes.
|
||||
const (
|
||||
deviceRunnerScheme = "CompanionRunner"
|
||||
deviceRunnerProject = "CompanionRunner.xcodeproj"
|
||||
)
|
||||
|
||||
// signingCredentials carries the no-UI signing inputs read from the environment.
|
||||
type signingCredentials struct {
|
||||
team string
|
||||
authKeyPath string
|
||||
authKeyID string
|
||||
authIssuerID string
|
||||
}
|
||||
|
||||
// readSigningCredentials gathers the signing inputs from the environment and
|
||||
// reports every missing one at once. The .p8 key must exist on disk.
|
||||
func readSigningCredentials() (signingCredentials, error) {
|
||||
team := os.Getenv(envTeam)
|
||||
if team == "" {
|
||||
team = os.Getenv(envTeamFallback)
|
||||
}
|
||||
creds := signingCredentials{
|
||||
team: team,
|
||||
authKeyPath: os.Getenv(envAuthKeyPath),
|
||||
authKeyID: os.Getenv(envAuthKeyID),
|
||||
authIssuerID: os.Getenv(envAuthIssuer),
|
||||
}
|
||||
var missing []string
|
||||
if creds.team == "" {
|
||||
missing = append(missing, envTeam)
|
||||
}
|
||||
if creds.authKeyPath == "" {
|
||||
missing = append(missing, envAuthKeyPath)
|
||||
}
|
||||
if creds.authKeyID == "" {
|
||||
missing = append(missing, envAuthKeyID)
|
||||
}
|
||||
if creds.authIssuerID == "" {
|
||||
missing = append(missing, envAuthIssuer)
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return creds, fmt.Errorf("device signing requires environment variables: %s", strings.Join(missing, ", "))
|
||||
}
|
||||
// xcodebuild's -authenticationKeyPath demands an absolute path, but .env
|
||||
// files commonly carry a repo-relative one. Resolve it against the working
|
||||
// directory before the stat so a relative key still works.
|
||||
if absolute, err := filepath.Abs(creds.authKeyPath); err == nil {
|
||||
creds.authKeyPath = absolute
|
||||
}
|
||||
if _, err := os.Stat(creds.authKeyPath); err != nil {
|
||||
return creds, fmt.Errorf("App Store Connect key not found at %s: %w", creds.authKeyPath, err)
|
||||
}
|
||||
return creds, nil
|
||||
}
|
||||
|
||||
// VerifyDeviceSigning reports whether the device signing environment is complete
|
||||
// and the App Store Connect key file exists. The doctor calls it so the device
|
||||
// preflight surfaces missing credentials before a run reaches the build step.
|
||||
func VerifyDeviceSigning() error {
|
||||
_, err := readSigningCredentials()
|
||||
return err
|
||||
}
|
||||
|
||||
// realSpawnDeviceRunner regenerates the runner project, builds it for the device
|
||||
// (skipping when the cached build matches the current sources), and spawns the
|
||||
// test session that hosts the runner on the device, passing the session port via
|
||||
// TEST_RUNNER_COMPANION_PORT. address carries the host loopback port, reused as
|
||||
// the device-side COMPANION_PORT.
|
||||
func (d *Driver) realSpawnDeviceRunner(ctx context.Context, address string) (*exec.Cmd, error) {
|
||||
_, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
companionDir, err := resolveCompanionDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
creds, err := readSigningCredentials()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
derivedDataPath := filepath.Join(os.TempDir(), "sanderling-device-runner")
|
||||
if err := os.MkdirAll(derivedDataPath, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := d.buildDeviceRunnerIfNeeded(ctx, companionDir, derivedDataPath, creds); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
xctestrunPath, err := locateDeviceXctestrun(derivedDataPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logPath := filepath.Join(derivedDataPath, "device-session-"+port+".log")
|
||||
logFile, err := os.Create(logPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create device session log: %w", err)
|
||||
}
|
||||
|
||||
args := testWithoutBuildingArgs(xctestrunPath, d.udid, creds)
|
||||
command := exec.CommandContext(ctx, "xcrun", args...)
|
||||
command.Stdout = logFile
|
||||
command.Stderr = logFile
|
||||
// Minimal environment: the session can echo its environment into the run
|
||||
// log, so secrets in the parent environment must not reach it. Signing is
|
||||
// passed by flag (a key-file path), not env. xcodebuild forwards any
|
||||
// TEST_RUNNER_-prefixed var into the runner with the prefix stripped, so the
|
||||
// non-secret COMPANION_PORT reaches the device that way instead of a plist
|
||||
// patch.
|
||||
command.Env = []string{
|
||||
"HOME=" + os.Getenv("HOME"),
|
||||
"PATH=/usr/bin:/bin",
|
||||
"TMPDIR=" + os.TempDir(),
|
||||
"TEST_RUNNER_COMPANION_PORT=" + port,
|
||||
}
|
||||
command.Cancel = func() error { return command.Process.Signal(syscall.SIGTERM) }
|
||||
command.WaitDelay = shutdownGrace
|
||||
startErr := command.Start()
|
||||
logFile.Close()
|
||||
if startErr != nil {
|
||||
return nil, fmt.Errorf("start device session: %w", startErr)
|
||||
}
|
||||
fmt.Fprintf(d.output, "device runner session pid=%d port=%s (log: %s)\n", command.Process.Pid, port, logPath)
|
||||
return command, nil
|
||||
}
|
||||
|
||||
// buildDeviceRunnerIfNeeded regenerates the project and runs build-for-testing,
|
||||
// skipping the build when a marker recording the current build key already
|
||||
// matches. The device signature is per-account/per-device, so the build cannot
|
||||
// be embedded; the stable derivedDataPath makes the build incremental.
|
||||
func (d *Driver) buildDeviceRunnerIfNeeded(ctx context.Context, companionDir, derivedDataPath string, creds signingCredentials) error {
|
||||
key, err := buildCacheKey(companionDir, creds)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
marker := filepath.Join(derivedDataPath, "device-runner.sha256")
|
||||
if existing, readErr := os.ReadFile(marker); readErr == nil && string(existing) == key {
|
||||
fmt.Fprintln(d.output, "device runner build is up to date; skipping build")
|
||||
return nil
|
||||
}
|
||||
|
||||
if out, genErr := runQuiet(ctx, companionDir, "xcodegen", "--spec", filepath.Join(companionDir, "project.yml")); genErr != nil {
|
||||
return fmt.Errorf("xcodegen: %w: %s", genErr, strings.TrimSpace(string(out)))
|
||||
}
|
||||
|
||||
projectPath := filepath.Join(companionDir, deviceRunnerProject)
|
||||
args := buildForTestingArgs(projectPath, derivedDataPath, creds)
|
||||
fmt.Fprintln(d.output, "building device runner (first run is slow; subsequent runs are cached)")
|
||||
if out, buildErr := runQuiet(ctx, companionDir, append([]string{"xcrun"}, args...)...); buildErr != nil {
|
||||
return fmt.Errorf("build-for-testing: %w: %s", buildErr, tailLines(string(out), 20))
|
||||
}
|
||||
if err := os.WriteFile(marker, []byte(key), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildCacheKey combines the source hash with the signing identity so a changed
|
||||
// team or key invalidates the cached build. A runner signed with a stale
|
||||
// identity would otherwise be reused and rejected at install (0xe8008018).
|
||||
func buildCacheKey(companionDir string, creds signingCredentials) (string, error) {
|
||||
sources, err := sourceHash(companionDir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sum := sha256.Sum256([]byte(sources + "\x00" + creds.team + "\x00" + creds.authKeyID))
|
||||
return hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
// buildForTestingArgs builds the runner for a generic device destination, signed
|
||||
// through the App Store Connect API key with automatic provisioning. A generic
|
||||
// destination keeps the build off any specific booted device; the wildcard dev
|
||||
// profile covers every provisioned device in the team.
|
||||
func buildForTestingArgs(projectPath, derivedDataPath string, creds signingCredentials) []string {
|
||||
return []string{"xcodebuild", "build-for-testing",
|
||||
"-project", projectPath,
|
||||
"-scheme", deviceRunnerScheme,
|
||||
"-destination", "generic/platform=iOS",
|
||||
"-derivedDataPath", derivedDataPath,
|
||||
"-allowProvisioningUpdates",
|
||||
"-authenticationKeyPath", creds.authKeyPath,
|
||||
"-authenticationKeyID", creds.authKeyID,
|
||||
"-authenticationKeyIssuerID", creds.authIssuerID,
|
||||
// companion/project.yml disables signing for the simulator build; the
|
||||
// device install rejects an unsigned runner (0xe8008018), so signing is
|
||||
// re-enabled here and the team drives automatic provisioning.
|
||||
"CODE_SIGNING_ALLOWED=YES",
|
||||
"CODE_SIGNING_REQUIRED=YES",
|
||||
"CODE_SIGN_STYLE=Automatic",
|
||||
"DEVELOPMENT_TEAM=" + creds.team,
|
||||
"GENERATE_INFOPLIST_FILE=YES",
|
||||
}
|
||||
}
|
||||
|
||||
// testWithoutBuildingArgs runs the prebuilt runner's test session on the
|
||||
// specific device, installing the signed runner via the same automatic
|
||||
// provisioning the build used.
|
||||
func testWithoutBuildingArgs(xctestrunPath, hardwareUDID string, creds signingCredentials) []string {
|
||||
return []string{"xcodebuild", "test-without-building",
|
||||
"-xctestrun", xctestrunPath,
|
||||
"-destination", "platform=iOS,id=" + hardwareUDID,
|
||||
"-allowProvisioningUpdates",
|
||||
"-authenticationKeyPath", creds.authKeyPath,
|
||||
"-authenticationKeyID", creds.authKeyID,
|
||||
"-authenticationKeyIssuerID", creds.authIssuerID,
|
||||
}
|
||||
}
|
||||
|
||||
// locateDeviceXctestrun finds the device build's xctestrun under the derived
|
||||
// data products. The name embeds the device SDK version, so it is discovered
|
||||
// rather than hardcoded.
|
||||
func locateDeviceXctestrun(derivedDataPath string) (string, error) {
|
||||
products := filepath.Join(derivedDataPath, "Build", "Products")
|
||||
entries, err := os.ReadDir(products)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read build products: %w", err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if strings.HasSuffix(entry.Name(), ".xctestrun") {
|
||||
return filepath.Join(products, entry.Name()), nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no xctestrun under %s", products)
|
||||
}
|
||||
|
||||
// sourceHash digests the runner sources and project spec so a source edit
|
||||
// invalidates the cached device build. Mirrors the runnerassets checksum reuse.
|
||||
func sourceHash(companionDir string) (string, error) {
|
||||
var paths []string
|
||||
sourcesDir := filepath.Join(companionDir, "Sources")
|
||||
walkErr := filepath.Walk(sourcesDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if walkErr != nil {
|
||||
return "", walkErr
|
||||
}
|
||||
paths = append(paths, filepath.Join(companionDir, "project.yml"))
|
||||
sort.Strings(paths)
|
||||
hash := sha256.New()
|
||||
for _, path := range paths {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
fmt.Fprintf(hash, "%s\n", path)
|
||||
hash.Write(content)
|
||||
}
|
||||
return hex.EncodeToString(hash.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// resolveCompanionDir finds the companion source tree: the SANDERLING_COMPANION_DIR
|
||||
// override if set, otherwise the nearest ancestor of the working directory that
|
||||
// holds companion/project.yml. The device runner is built from source at run
|
||||
// time, so the tree must be present (it is, in a source checkout).
|
||||
func resolveCompanionDir() (string, error) {
|
||||
if override := os.Getenv(envCompanionDir); override != "" {
|
||||
if _, err := os.Stat(filepath.Join(override, "project.yml")); err != nil {
|
||||
return "", fmt.Errorf("%s=%s has no project.yml: %w", envCompanionDir, override, err)
|
||||
}
|
||||
return override, nil
|
||||
}
|
||||
directory, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for {
|
||||
candidate := filepath.Join(directory, "companion")
|
||||
if _, statErr := os.Stat(filepath.Join(candidate, "project.yml")); statErr == nil {
|
||||
return candidate, nil
|
||||
}
|
||||
parent := filepath.Dir(directory)
|
||||
if parent == directory {
|
||||
break
|
||||
}
|
||||
directory = parent
|
||||
}
|
||||
return "", fmt.Errorf("companion source tree not found; run from a sanderling checkout or set %s", envCompanionDir)
|
||||
}
|
||||
|
||||
// runQuiet runs a command in dir with the inherited environment and returns its
|
||||
// combined output. Used for the transient build steps (xcodegen, xcodebuild
|
||||
// build-for-testing) whose output is surfaced only on failure.
|
||||
func runQuiet(ctx context.Context, dir string, args ...string) ([]byte, error) {
|
||||
command := exec.CommandContext(ctx, args[0], args[1:]...)
|
||||
command.Dir = dir
|
||||
return command.CombinedOutput()
|
||||
}
|
||||
|
||||
// tailLines returns the last n lines of text, so a long xcodebuild failure log
|
||||
// surfaces its tail (where the error is) without flooding the run output.
|
||||
func tailLines(text string, n int) string {
|
||||
lines := strings.Split(strings.TrimRight(text, "\n"), "\n")
|
||||
if len(lines) <= n {
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
return strings.Join(lines[len(lines)-n:], "\n")
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package ioscompanion
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadSigningCredentialsReportsMissing(t *testing.T) {
|
||||
for _, key := range []string{envTeam, envTeamFallback, envAuthKeyPath, envAuthKeyID, envAuthIssuer} {
|
||||
t.Setenv(key, "")
|
||||
}
|
||||
_, err := readSigningCredentials()
|
||||
if err == nil {
|
||||
t.Fatal("missing credentials must error")
|
||||
}
|
||||
for _, want := range []string{envTeam, envAuthKeyPath, envAuthKeyID, envAuthIssuer} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("error should name missing var %q: %v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadSigningCredentialsRejectsMissingKeyFile(t *testing.T) {
|
||||
t.Setenv(envTeam, "TEAM1")
|
||||
t.Setenv(envAuthKeyID, "KID")
|
||||
t.Setenv(envAuthIssuer, "ISS")
|
||||
t.Setenv(envAuthKeyPath, filepath.Join(t.TempDir(), "absent.p8"))
|
||||
if _, err := readSigningCredentials(); err == nil {
|
||||
t.Fatal("a missing .p8 key file must error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadSigningCredentialsAcceptsPresentKey(t *testing.T) {
|
||||
keyPath := filepath.Join(t.TempDir(), "AuthKey.p8")
|
||||
if err := os.WriteFile(keyPath, []byte("-----BEGIN PRIVATE KEY-----"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv(envTeam, "")
|
||||
t.Setenv(envTeamFallback, "FALLBACKTEAM")
|
||||
t.Setenv(envAuthKeyID, "KID")
|
||||
t.Setenv(envAuthIssuer, "ISS")
|
||||
t.Setenv(envAuthKeyPath, keyPath)
|
||||
creds, err := readSigningCredentials()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if creds.team != "FALLBACKTEAM" {
|
||||
t.Fatalf("team = %q, want the DEVELOPMENT_TEAM fallback", creds.team)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadSigningCredentialsResolvesRelativeKeyPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Chdir(dir)
|
||||
if err := os.WriteFile("AuthKey.p8", []byte("key"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv(envTeam, "TEAM1")
|
||||
t.Setenv(envAuthKeyID, "KID")
|
||||
t.Setenv(envAuthIssuer, "ISS")
|
||||
t.Setenv(envAuthKeyPath, "AuthKey.p8")
|
||||
creds, err := readSigningCredentials()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !filepath.IsAbs(creds.authKeyPath) {
|
||||
t.Fatalf("authKeyPath = %q, want an absolute path for xcodebuild", creds.authKeyPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCacheKeyChangesWithSigningIdentity(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "Sources"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "Sources", "Server.swift"), []byte("v1"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "project.yml"), []byte("name: x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base, err := buildCacheKey(dir, signingCredentials{team: "TEAM1", authKeyID: "KID1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
otherTeam, err := buildCacheKey(dir, signingCredentials{team: "TEAM2", authKeyID: "KID1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
otherKey, err := buildCacheKey(dir, signingCredentials{team: "TEAM1", authKeyID: "KID2"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if base == otherTeam {
|
||||
t.Fatal("a changed team must invalidate the cached build")
|
||||
}
|
||||
if base == otherKey {
|
||||
t.Fatal("a changed signing key must invalidate the cached build")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceHashChangesWithSources(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "Sources"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "Sources", "Server.swift"), []byte("v1"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "project.yml"), []byte("name: x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := sourceHash(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "Sources", "Server.swift"), []byte("v2"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := sourceHash(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first == second {
|
||||
t.Fatal("a source edit must change the hash so the cached build is invalidated")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
// This file implements the host-to-device TCP forward the device runner needs,
|
||||
// natively, against macOS's own usbmuxd. The runner exposes a JSON-RPC server on
|
||||
// the device loopback; the host dials it through this forward. usbmuxd is the
|
||||
// macOS daemon at /var/run/usbmuxd that already multiplexes every USB device
|
||||
// connection; a third-party client (iproxy and the like) is only a thin speaker
|
||||
// of the same protocol, so talking to the socket directly keeps the device path
|
||||
// dependent on nothing beyond the OS.
|
||||
package ioscompanion
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// usbmuxdSocket is the macOS usbmuxd unix socket. It is part of the base OS, not
|
||||
// an installed dependency.
|
||||
const usbmuxdSocket = "/var/run/usbmuxd"
|
||||
|
||||
// VerifyUsbmuxdSocket reports whether the macOS usbmuxd socket is present. The
|
||||
// doctor calls it so the device preflight confirms the tunnel's transport
|
||||
// before a run reaches the build step.
|
||||
func VerifyUsbmuxdSocket() error {
|
||||
if _, err := os.Stat(usbmuxdSocket); err != nil {
|
||||
return fmt.Errorf("usbmuxd socket not found at %s: %w", usbmuxdSocket, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// usbmux message framing: a 16-byte little-endian header (length including the
|
||||
// header, protocol version, payload type, request tag) precedes an XML plist.
|
||||
const (
|
||||
usbmuxHeaderLength = 16
|
||||
usbmuxVersion = 1
|
||||
usbmuxPayloadPlist = 8
|
||||
usbmuxTag = 1
|
||||
)
|
||||
|
||||
// usbmuxDial opens a live byte pipe to devicePort on the device identified by
|
||||
// hardwareUDID: it resolves the device's usbmux id, then issues a Connect whose
|
||||
// success turns the usbmuxd socket into a raw conduit to that device port. The
|
||||
// returned net.Conn is the device side of the runner's TCP server.
|
||||
func usbmuxDial(ctx context.Context, hardwareUDID string, devicePort int) (net.Conn, error) {
|
||||
deviceID, err := usbmuxDeviceID(ctx, hardwareUDID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
conn, err := dialUsbmuxd(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// usbmux carries the port in network byte order.
|
||||
request, err := encodePlistDict(map[string]any{
|
||||
"MessageType": "Connect",
|
||||
"DeviceID": deviceID,
|
||||
"PortNumber": int(htons(uint16(devicePort))),
|
||||
})
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := writeUsbmuxMessage(conn, request); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
reply, err := readUsbmuxMessage(conn)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
result, err := parsePlistDict(reply)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
if number, _ := result["Number"].(int); number != 0 {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("usbmux: connect to device port %d failed (result %d)", devicePort, number)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// usbmuxDeviceID lists attached devices and returns the usbmux id of the one
|
||||
// whose serial matches hardwareUDID. usbmux ids are assigned per attachment and
|
||||
// can change across reconnects, so it is resolved fresh on every dial.
|
||||
func usbmuxDeviceID(ctx context.Context, hardwareUDID string) (int, error) {
|
||||
conn, err := dialUsbmuxd(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
request, err := encodePlistDict(map[string]any{"MessageType": "ListDevices"})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := writeUsbmuxMessage(conn, request); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
reply, err := readUsbmuxMessage(conn)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
list, err := parsePlistDict(reply)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return selectDeviceID(list, hardwareUDID)
|
||||
}
|
||||
|
||||
// selectDeviceID picks the usbmux DeviceID for hardwareUDID from a parsed
|
||||
// ListDevices reply, matching the serial with dashes and case ignored so the
|
||||
// devicectl HardwareUDID (dashed) and the raw USB serial (undashed) both resolve.
|
||||
func selectDeviceID(list map[string]any, hardwareUDID string) (int, error) {
|
||||
devices, _ := list["DeviceList"].([]any)
|
||||
target := normalizeSerial(hardwareUDID)
|
||||
for _, entry := range devices {
|
||||
device, ok := entry.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
properties, _ := device["Properties"].(map[string]any)
|
||||
serial, _ := properties["SerialNumber"].(string)
|
||||
if normalizeSerial(serial) != target {
|
||||
continue
|
||||
}
|
||||
if id, ok := device["DeviceID"].(int); ok {
|
||||
return id, nil
|
||||
}
|
||||
if id, ok := properties["DeviceID"].(int); ok {
|
||||
return id, nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("usbmux: device %s not attached", hardwareUDID)
|
||||
}
|
||||
|
||||
func normalizeSerial(serial string) string {
|
||||
return strings.ToLower(strings.ReplaceAll(serial, "-", ""))
|
||||
}
|
||||
|
||||
// htons swaps a port to network byte order, as the usbmux Connect PortNumber
|
||||
// requires.
|
||||
func htons(port uint16) uint16 {
|
||||
return port<<8 | port>>8
|
||||
}
|
||||
|
||||
func dialUsbmuxd(ctx context.Context) (net.Conn, error) {
|
||||
dialer := net.Dialer{}
|
||||
conn, err := dialer.DialContext(ctx, "unix", usbmuxdSocket)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("usbmux: dial %s: %w", usbmuxdSocket, err)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func writeUsbmuxMessage(conn net.Conn, payload []byte) error {
|
||||
header := make([]byte, usbmuxHeaderLength)
|
||||
binary.LittleEndian.PutUint32(header[0:4], uint32(usbmuxHeaderLength+len(payload)))
|
||||
binary.LittleEndian.PutUint32(header[4:8], usbmuxVersion)
|
||||
binary.LittleEndian.PutUint32(header[8:12], usbmuxPayloadPlist)
|
||||
binary.LittleEndian.PutUint32(header[12:16], usbmuxTag)
|
||||
if _, err := conn.Write(header); err != nil {
|
||||
return fmt.Errorf("usbmux: write header: %w", err)
|
||||
}
|
||||
if _, err := conn.Write(payload); err != nil {
|
||||
return fmt.Errorf("usbmux: write payload: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readUsbmuxMessage(conn net.Conn) ([]byte, error) {
|
||||
header := make([]byte, usbmuxHeaderLength)
|
||||
if _, err := io.ReadFull(conn, header); err != nil {
|
||||
return nil, fmt.Errorf("usbmux: read header: %w", err)
|
||||
}
|
||||
length := binary.LittleEndian.Uint32(header[0:4])
|
||||
if length < usbmuxHeaderLength {
|
||||
return nil, fmt.Errorf("usbmux: reply length %d shorter than header", length)
|
||||
}
|
||||
payload := make([]byte, length-usbmuxHeaderLength)
|
||||
if _, err := io.ReadFull(conn, payload); err != nil {
|
||||
return nil, fmt.Errorf("usbmux: read payload: %w", err)
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// encodePlistDict renders a flat dict (string or int values) as the XML plist
|
||||
// usbmux requests use. Keys are sorted so the output is deterministic.
|
||||
func encodePlistDict(fields map[string]any) ([]byte, error) {
|
||||
var buffer bytes.Buffer
|
||||
buffer.WriteString(`<?xml version="1.0" encoding="UTF-8"?>` + "\n")
|
||||
buffer.WriteString(`<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">` + "\n")
|
||||
buffer.WriteString(`<plist version="1.0">` + "\n<dict>\n")
|
||||
keys := make([]string, 0, len(fields))
|
||||
for key := range fields {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
fmt.Fprintf(&buffer, "<key>%s</key>", key)
|
||||
switch value := fields[key].(type) {
|
||||
case string:
|
||||
buffer.WriteString("<string>")
|
||||
xml.EscapeText(&buffer, []byte(value))
|
||||
buffer.WriteString("</string>\n")
|
||||
case int:
|
||||
fmt.Fprintf(&buffer, "<integer>%d</integer>\n", value)
|
||||
default:
|
||||
return nil, fmt.Errorf("usbmux: unsupported plist value type %T for key %s", value, key)
|
||||
}
|
||||
}
|
||||
buffer.WriteString("</dict>\n</plist>\n")
|
||||
return buffer.Bytes(), nil
|
||||
}
|
||||
|
||||
// parsePlistDict parses an XML plist whose root is a dict into a generic map.
|
||||
func parsePlistDict(data []byte) (map[string]any, error) {
|
||||
value, err := parsePlist(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dict, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("usbmux: plist root is %T, want dict", value)
|
||||
}
|
||||
return dict, nil
|
||||
}
|
||||
|
||||
// parsePlist decodes an XML plist into nested map[string]any / []any / string /
|
||||
// int / bool values. It covers the element set usbmux replies use.
|
||||
func parsePlist(data []byte) (any, error) {
|
||||
decoder := xml.NewDecoder(bytes.NewReader(data))
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("usbmux: parse plist: %w", err)
|
||||
}
|
||||
if start, ok := token.(xml.StartElement); ok && start.Name.Local == "plist" {
|
||||
return parsePlistChild(decoder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parsePlistChild reads forward to the next start element and parses it as a
|
||||
// value. It is used for the lone child of <plist> and of each <key>.
|
||||
func parsePlistChild(decoder *xml.Decoder) (any, error) {
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch element := token.(type) {
|
||||
case xml.StartElement:
|
||||
return parsePlistElement(decoder, element)
|
||||
case xml.EndElement:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parsePlistElement(decoder *xml.Decoder, start xml.StartElement) (any, error) {
|
||||
switch start.Name.Local {
|
||||
case "dict":
|
||||
return parsePlistDictBody(decoder)
|
||||
case "array":
|
||||
return parsePlistArray(decoder)
|
||||
case "string":
|
||||
return parsePlistText(decoder)
|
||||
case "integer":
|
||||
text, err := parsePlistText(decoder)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
number, err := strconv.Atoi(strings.TrimSpace(text))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("usbmux: parse integer %q: %w", text, err)
|
||||
}
|
||||
return number, nil
|
||||
case "true":
|
||||
return true, decoder.Skip()
|
||||
case "false":
|
||||
return false, decoder.Skip()
|
||||
default:
|
||||
// Unhandled scalar (real, data, date): consume it and report nil so an
|
||||
// unexpected field never aborts parsing the fields that matter.
|
||||
return nil, decoder.Skip()
|
||||
}
|
||||
}
|
||||
|
||||
func parsePlistDictBody(decoder *xml.Decoder) (map[string]any, error) {
|
||||
result := map[string]any{}
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch element := token.(type) {
|
||||
case xml.StartElement:
|
||||
if element.Name.Local != "key" {
|
||||
return nil, fmt.Errorf("usbmux: expected <key>, got <%s>", element.Name.Local)
|
||||
}
|
||||
key, err := parsePlistText(decoder)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value, err := parsePlistChild(decoder)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[key] = value
|
||||
case xml.EndElement:
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parsePlistArray(decoder *xml.Decoder) ([]any, error) {
|
||||
var result []any
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch element := token.(type) {
|
||||
case xml.StartElement:
|
||||
value, err := parsePlistElement(decoder, element)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, value)
|
||||
case xml.EndElement:
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parsePlistText(decoder *xml.Decoder) (string, error) {
|
||||
var text strings.Builder
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch element := token.(type) {
|
||||
case xml.CharData:
|
||||
text.Write(element)
|
||||
case xml.EndElement:
|
||||
return text.String(), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// usbmuxForwarder is the in-process replacement for an iproxy child: it accepts
|
||||
// host loopback connections and bridges each to a fresh device-port conduit over
|
||||
// usbmux. It satisfies io.Closer so the driver tears it down like any other
|
||||
// tunnel handle; closing the listener ends the accept loop and the bridges drain
|
||||
// as their copies finish.
|
||||
type usbmuxForwarder struct {
|
||||
listener net.Listener
|
||||
dialDevice func(ctx context.Context) (net.Conn, error)
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
// startUsbmuxForwarder listens on localAddress and forwards every accepted
|
||||
// connection to devicePort on the device, over usbmux.
|
||||
func startUsbmuxForwarder(ctx context.Context, hardwareUDID, localAddress string, devicePort int) (*usbmuxForwarder, error) {
|
||||
return startForwarder(ctx, localAddress, func(dialCtx context.Context) (net.Conn, error) {
|
||||
return usbmuxDial(dialCtx, hardwareUDID, devicePort)
|
||||
})
|
||||
}
|
||||
|
||||
// startForwarder is the seam-friendly core: the device dialer is injected so a
|
||||
// test can bridge to an in-process echo server without a real device.
|
||||
func startForwarder(ctx context.Context, localAddress string, dialDevice func(context.Context) (net.Conn, error)) (*usbmuxForwarder, error) {
|
||||
listener, err := net.Listen("tcp", localAddress)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("usbmux forwarder: listen %s: %w", localAddress, err)
|
||||
}
|
||||
forwarder := &usbmuxForwarder{listener: listener, dialDevice: dialDevice, ctx: ctx}
|
||||
go forwarder.serve()
|
||||
return forwarder, nil
|
||||
}
|
||||
|
||||
func (f *usbmuxForwarder) serve() {
|
||||
for {
|
||||
hostConn, err := f.listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go f.bridge(hostConn)
|
||||
}
|
||||
}
|
||||
|
||||
// bridge connects to the device side, then copies bytes in both directions
|
||||
// until either end closes. A failed device dial (the runner not yet listening)
|
||||
// closes the host side, which the runner transport reads as a dropped
|
||||
// connection and recovers from on its next call.
|
||||
func (f *usbmuxForwarder) bridge(hostConn net.Conn) {
|
||||
defer hostConn.Close()
|
||||
deviceConn, err := f.dialDevice(f.ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer deviceConn.Close()
|
||||
done := make(chan struct{}, 2)
|
||||
go func() { io.Copy(deviceConn, hostConn); done <- struct{}{} }()
|
||||
go func() { io.Copy(hostConn, deviceConn); done <- struct{}{} }()
|
||||
// One direction ending closes both conns via the defers, which unblocks the
|
||||
// other copy; waiting for one is enough to know the bridge is finished.
|
||||
<-done
|
||||
}
|
||||
|
||||
func (f *usbmuxForwarder) Close() error {
|
||||
return f.listener.Close()
|
||||
}
|
||||
|
||||
// startUsbmuxTunnel adapts the forwarder to the driver's startTunnel seam,
|
||||
// returning it as an io.Closer.
|
||||
func startUsbmuxTunnel(ctx context.Context, hardwareUDID, localAddress, devicePort string) (io.Closer, error) {
|
||||
port, err := strconv.Atoi(devicePort)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("usbmux tunnel: device port %q: %w", devicePort, err)
|
||||
}
|
||||
return startUsbmuxForwarder(ctx, hardwareUDID, localAddress, port)
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package ioscompanion
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHtonsSwapsBytes(t *testing.T) {
|
||||
// 49200 = 0xC030; network order swaps to 0x30C0 = 12480.
|
||||
if got := htons(49200); got != 12480 {
|
||||
t.Fatalf("htons(49200) = %d, want 12480", got)
|
||||
}
|
||||
if got := htons(0x1234); got != 0x3412 {
|
||||
t.Fatalf("htons(0x1234) = %#x, want 0x3412", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodePlistDictRoundTrips(t *testing.T) {
|
||||
encoded, err := encodePlistDict(map[string]any{
|
||||
"MessageType": "Connect",
|
||||
"DeviceID": 7,
|
||||
"PortNumber": 12480,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dict, err := parsePlistDict(encoded)
|
||||
if err != nil {
|
||||
t.Fatalf("parse round-trip: %v", err)
|
||||
}
|
||||
if dict["MessageType"] != "Connect" {
|
||||
t.Fatalf("MessageType = %v, want Connect", dict["MessageType"])
|
||||
}
|
||||
if dict["DeviceID"] != 7 {
|
||||
t.Fatalf("DeviceID = %v, want 7", dict["DeviceID"])
|
||||
}
|
||||
if dict["PortNumber"] != 12480 {
|
||||
t.Fatalf("PortNumber = %v, want 12480", dict["PortNumber"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodePlistDictEscapesStrings(t *testing.T) {
|
||||
encoded, err := encodePlistDict(map[string]any{"Name": "a & b <c>"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dict, err := parsePlistDict(encoded)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if dict["Name"] != "a & b <c>" {
|
||||
t.Fatalf("Name = %q, want the unescaped original", dict["Name"])
|
||||
}
|
||||
}
|
||||
|
||||
// listDevicesReply is a representative usbmux ListDevices response with one USB
|
||||
// device, matching the shape macOS usbmuxd returns.
|
||||
const listDevicesReply = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>DeviceList</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>DeviceID</key>
|
||||
<integer>7</integer>
|
||||
<key>MessageType</key>
|
||||
<string>Attached</string>
|
||||
<key>Properties</key>
|
||||
<dict>
|
||||
<key>ConnectionType</key>
|
||||
<string>USB</string>
|
||||
<key>SerialNumber</key>
|
||||
<string>00008140-00022C4A3E13001C</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>`
|
||||
|
||||
func TestSelectDeviceIDMatchesDashedSerial(t *testing.T) {
|
||||
list, err := parsePlistDict([]byte(listDevicesReply))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id, err := selectDeviceID(list, "00008140-00022C4A3E13001C")
|
||||
if err != nil {
|
||||
t.Fatalf("select by dashed serial: %v", err)
|
||||
}
|
||||
if id != 7 {
|
||||
t.Fatalf("DeviceID = %d, want 7", id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectDeviceIDMatchesUndashedSerial(t *testing.T) {
|
||||
list, err := parsePlistDict([]byte(listDevicesReply))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The raw USB serial carries no dash; it must still resolve.
|
||||
id, err := selectDeviceID(list, "0000814000022C4A3E13001C")
|
||||
if err != nil {
|
||||
t.Fatalf("select by undashed serial: %v", err)
|
||||
}
|
||||
if id != 7 {
|
||||
t.Fatalf("DeviceID = %d, want 7", id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectDeviceIDNotAttached(t *testing.T) {
|
||||
list, err := parsePlistDict([]byte(listDevicesReply))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := selectDeviceID(list, "DEADBEEF-NOTHERE"); err == nil {
|
||||
t.Fatal("a serial not in the list must error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePlistDictRejectsNonDictRoot(t *testing.T) {
|
||||
arrayRoot := `<?xml version="1.0"?><plist version="1.0"><array></array></plist>`
|
||||
if _, err := parsePlistDict([]byte(arrayRoot)); err == nil {
|
||||
t.Fatal("a non-dict plist root must error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestForwarderBridgesToDevice drives the forwarder end to end against an
|
||||
// in-process echo "device": a host connection through the loopback listener must
|
||||
// reach the injected device dialer and round-trip bytes.
|
||||
func TestForwarderBridgesToDevice(t *testing.T) {
|
||||
deviceListener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer deviceListener.Close()
|
||||
go func() {
|
||||
for {
|
||||
conn, acceptErr := deviceListener.Accept()
|
||||
if acceptErr != nil {
|
||||
return
|
||||
}
|
||||
go io.Copy(conn, conn)
|
||||
}
|
||||
}()
|
||||
|
||||
localAddress, err := pickLoopbackAddress()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
forwarder, err := startForwarder(context.Background(), localAddress, func(ctx context.Context) (net.Conn, error) {
|
||||
return net.Dial("tcp", deviceListener.Addr().String())
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer forwarder.Close()
|
||||
|
||||
hostConn, err := net.Dial("tcp", localAddress)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer hostConn.Close()
|
||||
|
||||
message := []byte("ping\n")
|
||||
if _, err := hostConn.Write(message); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
buffer := make([]byte, len(message))
|
||||
if _, err := io.ReadFull(hostConn, buffer); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(buffer) != string(message) {
|
||||
t.Fatalf("round-trip = %q, want %q", buffer, message)
|
||||
}
|
||||
}
|
||||
|
||||
// TestForwarderClosesHostOnDeviceDialFailure asserts the failure path the runner
|
||||
// transport relies on: when the device dial fails (the runner not yet
|
||||
// listening), the forwarder closes the host side so the caller sees a dropped
|
||||
// connection and retries.
|
||||
func TestForwarderClosesHostOnDeviceDialFailure(t *testing.T) {
|
||||
localAddress, err := pickLoopbackAddress()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
forwarder, err := startForwarder(context.Background(), localAddress, func(ctx context.Context) (net.Conn, error) {
|
||||
return nil, io.ErrUnexpectedEOF
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer forwarder.Close()
|
||||
|
||||
hostConn, err := net.Dial("tcp", localAddress)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer hostConn.Close()
|
||||
|
||||
// The forwarder closes its side after the failed device dial; the read
|
||||
// returns EOF rather than blocking forever.
|
||||
if _, err := io.ReadFull(hostConn, make([]byte, 1)); err == nil {
|
||||
t.Fatal("read must fail once the forwarder closes the host side")
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwarderCloseStopsAcceptLoop(t *testing.T) {
|
||||
localAddress, err := pickLoopbackAddress()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
forwarder, err := startForwarder(context.Background(), localAddress, func(ctx context.Context) (net.Conn, error) {
|
||||
return nil, io.EOF
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := forwarder.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// After Close the listener is gone, so a dial must fail.
|
||||
if conn, dialErr := net.Dial("tcp", localAddress); dialErr == nil {
|
||||
conn.Close()
|
||||
t.Fatal("dial must fail after the forwarder is closed")
|
||||
}
|
||||
}
|
||||
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user