mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
iOS simulator driver: Go-native companion-backed backend (#62)
* perf(ios): use prebuilt XCTest runner to cut startup * chore(ioscompanion): add companion asset prepare script * feat(ioscompanion): embed and extract simulator companion bundle * test(ioscompanion): cover companion stub and embedded extraction * docs: add third party notices for vendored companion * chore: ignore vendored companion bundle artifact * build(proto): pin simulator companion proto v1.1.8 * build(proto): add dedicated buf module and gen template for pinned proto * build(proto): exclude pinned companion proto from root buf workspace * feat(ioscompanion): commit generated companion gRPC stubs * feat(ioscompanion): map flat companion describe dump to TreeNode JSON * test(ioscompanion): add hierarchy-map golden and unit tests * feat(ioscompanion): port screen-settle stability polling to Go * test(ioscompanion): cover settle transitional, hash, streak, and cap rules * feat(ioscompanion): add USB HID keymap module * test(ioscompanion): cover keymap branches and paste-chord constants * build: embed companion assets via withcompanion tag * feat(ioscompanion): add transport companion interface * feat(ioscompanion): add HID event wrapper and builders * feat(ioscompanion): wire gRPC companion client and Dial * test(ioscompanion): cover HID builders and unit conversions * test(ioscompanion): cover Dial, process-state mapping, and install archive * test(ioscompanion): add gated simulator integration smoke test * feat(ioscompanion): text input and gesture HID composition with pasteboard fallback * test(ioscompanion): cover input composers, paste dialog loop, and pure helpers * feat(ioscompanion): add Describe to companion transport * feat(ioscompanion): implement DeviceDriver with companion supervision * test(ioscompanion): unit tests with fake companion transport * test(ioscompanion): gated companion smoke test * feat(ios): add ResolveTarget for simulator vs physical-device routing * feat(testrun): route iOS simulators through the native companion driver * refactor(testrun): defer the java preflight check to the physical-device path * feat(cli): add --ios-app-path flag * feat(doctor): split iOS checks into simulator and physical-device paths * test(folio): add gate-analyzer fixtures for G1-G5 * feat(folio): add iOS conformance gate script * chore(folio): wire gates recipe, app path, and ignore gate output * style: gofmt struct alignment drift * fix(doctor): probe simctl via xcrun instead of PATH lookup * fix(ioscompanion): spawn companion under driver-lifetime context * test(ioscompanion): prove companion child outlives startup context * fix(ioscompanion): chunk install payload under companion message cap * test(ioscompanion): cover install payload chunking * fix(ioscompanion): reinstall via simctl and sanitize companion env * fix(ioscompanion): wait out unresolved accessibility values after launch * perf(ioscompanion): paste long text for atomic landing * test(ioscompanion): cover paste threshold, retry flow, and sentinel detection * fix(ioscompanion): treat unresolved bridge values as transitional, never as content * fix(ioscompanion): accept masked secure-field values as paste landing * test(ioscompanion): cover sentinel mapping and masked-field landing * fix(ioscompanion): atomic erase and single-send paste to prevent doubling * test(ioscompanion): cover atomic erase, single chord, unverifiable field * fix(ioscompanion): verify paste on a time budget that outlasts the bridge blackout * test(ioscompanion): cover bridge-blackout paste verification * fix(ioscompanion): drop unresolved-value settle gate that never let empty-field screens settle * refactor(ioscompanion): name the empty-editable-field sentinel for what it is * perf(ioscompanion): tighten settle streak for the fast companion transport * feat(ioscompanion): pre-grant pasteboard access so unicode input skips the OS prompt * refactor(ioscompanion): drop paste warm-up now that the grant suppresses the prompt * test(ioscompanion): cover pasteboard grant on launch, drop warm-up tests * fix(ioscompanion): retry describe past transient collapsed accessibility dumps * test(ioscompanion): cover collapsed-dump detection * perf(ioscompanion): split raw and retrying describe so settle does not double-wait collapses * perf(ioscompanion): tighten settle now that collapses are handled separately * fix(ioscompanion): replace field content on input so blackout-skipped erase cannot accumulate text * test(ioscompanion): cover replace-on-input and TextReplacer capability * refactor(ioscompanion): neutralize HID events behind the transport seam * feat(companion): add simulator runner project skeleton * feat(companion): serve accessibility snapshots over the wire protocol * feat(companion): synthesize timestamped touch gestures * feat(companion): type text with replace semantics * feat(companion): serve the wire protocol from a parked runner * feat(ioscompanion): add TextEditor capability and unavailable sentinel to the transport seam * feat(ioscompanion): route text input through a text-editing companion when available * fix(companion): bind listener by port and source screen size from snapshot * feat(ioscompanion): add runner companion JSON transport * test(ioscompanion): cover runner transport protocol mapping * fix(companion): synthesize gestures synchronously to avoid the async completion crash * fix(companion): type on the main thread and recover from focus assertions * fix(companion): keep serving after an automation failure * refactor(companion): tidy snapshot serialization * fix(companion): honor sequential tap gaps and survive synthesis exceptions * feat(ioscompanion): expose native typing with an explicit replace flag * chore(companion): add runner asset prepare script * feat(ioscompanion): embed and extract the runner test bundle * test(ioscompanion): cover runner asset extraction * build(ioscompanion): commit runner asset archive * feat(ioscompanion): pair the legacy companion with the in-simulator runner * test(ioscompanion): cover hybrid routing, paste-grant skip, and port binding * fix(ioscompanion): reconnect after interrupted runner calls instead of restarting * fix(ioscompanion): route hybrid lifecycle through the runner and harden restarts * feat(companion): launch and terminate apps through the automation session * build(ioscompanion): refresh runner asset with session lifecycle * fix(ioscompanion): classify connection deadline expiry as caller budget * fix(companion): capture snapshots on the main thread inside the catch bridge * build(ioscompanion): refresh runner asset with main-thread snapshots * perf(ioscompanion): count read spans toward settle and capture snapshots concurrently * feat(ioscompanion): make the hybrid simulator companion the default * test(folio): cover runner-session orphans in the gate harness * test(ioscompanion): pin the child-lifetime test to the legacy path * fix(ioscompanion): keep mappable text on one HID stream and verify unicode clears * fix(ioscompanion): pause the clear chord so selection applies before the delete * fix(companion): prune the keyboard subtree from snapshots * build(ioscompanion): refresh runner asset without keyboard elements * fix(ioscompanion): capture the screenshot transport before a recovery can reassign it * fix(companion): pin the runner listener to loopback * fix(companion): size the replace delete prefix to cover any focused field * build(ioscompanion): refresh runner asset with loopback bind and replace fix * fix(cli): cancel the run context on SIGINT so spawned children are reaped * fix(testrun): point the device java preflight hint at the ios-device doctor * fix(folio): word-bound the G2 ERROR scan and drop the dead objc allowlist glob * test(ioscompanion): cover stopProcess, restart, and failed bring-up supervision * chore: add test-companion target for the withcompanion-tagged suite * chore(ioscompanion): stop tracking the runner archive build artifact * build: produce the runner archive from source like the companion bundle * refactor(conformance): move the gate harness out of examples/folio * chore(folio): drop the gate harness wiring from the example app
This commit is contained in:
97 files changed
+22104
-83
No files matched your search
+12
@@ -32,6 +32,14 @@ node_modules/
|
|||||||
# `go build -tags withsidecar`. Never commit: it's ~130 MB.
|
# `go build -tags withsidecar`. Never commit: it's ~130 MB.
|
||||||
internal/sidecarassets/assets/sidecar-all.jar
|
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
|
# spec-api compiled output
|
||||||
pkg/spec/dist/
|
pkg/spec/dist/
|
||||||
|
|
||||||
@@ -48,6 +56,10 @@ replay-ui/node_modules/
|
|||||||
replay-ui/dist/
|
replay-ui/dist/
|
||||||
replay-ui/.vite/
|
replay-ui/.vite/
|
||||||
|
|
||||||
|
# Simulator runner generated project and build output (xcodegen artifacts)
|
||||||
|
companion/CompanionRunner.xcodeproj/
|
||||||
|
companion/build/
|
||||||
|
|
||||||
# folio-web frontend
|
# folio-web frontend
|
||||||
examples/folio-web/node_modules/
|
examples/folio-web/node_modules/
|
||||||
examples/folio-web/dist/
|
examples/folio-web/dist/
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ BUF := buf
|
|||||||
GO_PACKAGES := ./...
|
GO_PACKAGES := ./...
|
||||||
SIDECAR_JAR := sidecar/build/libs/sidecar-all.jar
|
SIDECAR_JAR := sidecar/build/libs/sidecar-all.jar
|
||||||
SIDECAR_EMBED := internal/sidecarassets/assets/sidecar-all.jar
|
SIDECAR_EMBED := internal/sidecarassets/assets/sidecar-all.jar
|
||||||
|
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) build.gradle.kts settings.gradle.kts
|
SIDECAR_SRC := $(shell find sidecar/src -type f \( -name '*.kt' -o -name '*.kts' \) 2>/dev/null) build.gradle.kts settings.gradle.kts
|
||||||
SANDERLING_BIN := bin/sanderling
|
SANDERLING_BIN := bin/sanderling
|
||||||
|
|
||||||
@@ -22,7 +27,7 @@ DOCS_TEMPLATE := docs/_template/page.html
|
|||||||
REPLAY_DIST := internal/replay/dist
|
REPLAY_DIST := internal/replay/dist
|
||||||
WEB_DIST := replay-ui/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:
|
bootstrap:
|
||||||
$(GO) mod download
|
$(GO) mod download
|
||||||
@@ -37,14 +42,14 @@ sidecar: $(SIDECAR_JAR)
|
|||||||
|
|
||||||
sanderling: $(SANDERLING_BIN)
|
sanderling: $(SANDERLING_BIN)
|
||||||
|
|
||||||
$(SANDERLING_BIN): $(SIDECAR_EMBED) web-build
|
$(SANDERLING_BIN): $(SIDECAR_EMBED) $(COMPANION_EMBED) $(RUNNER_EMBED) web-build
|
||||||
mkdir -p bin
|
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
|
# Installs `sanderling` into $GOBIN (or $GOPATH/bin) so it's directly on PATH for
|
||||||
# anyone with a standard Go toolchain setup.
|
# anyone with a standard Go toolchain setup.
|
||||||
install: $(SIDECAR_EMBED) web-build
|
install: $(SIDECAR_EMBED) $(COMPANION_EMBED) $(RUNNER_EMBED) web-build
|
||||||
$(GO) install -tags withsidecar ./cmd/sanderling
|
$(GO) install -tags "withsidecar withcompanion" ./cmd/sanderling
|
||||||
@dest="$$($(GO) env GOBIN)"; [ -n "$$dest" ] || dest="$$($(GO) env GOPATH)/bin"; echo "installed sanderling to $$dest"
|
@dest="$$($(GO) env GOBIN)"; [ -n "$$dest" ] || dest="$$($(GO) env GOPATH)/bin"; echo "installed sanderling to $$dest"
|
||||||
|
|
||||||
web-build:
|
web-build:
|
||||||
@@ -69,6 +74,12 @@ $(SIDECAR_EMBED): $(SIDECAR_JAR)
|
|||||||
mkdir -p $(dir $@)
|
mkdir -p $(dir $@)
|
||||||
cp $< $@
|
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: test-go test-spec-api web-typecheck web-test
|
||||||
|
|
||||||
test-go:
|
test-go:
|
||||||
@@ -82,6 +93,12 @@ web-test:
|
|||||||
test-browser:
|
test-browser:
|
||||||
$(GO) test -tags browser ./test/browser/... ./internal/driver/chrome/...
|
$(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:
|
test-kotlin:
|
||||||
ANDROID_HOME=$(ANDROID_HOME) $(GRADLE) :sidecar:test
|
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
|
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:
|
modules:
|
||||||
- path: proto
|
- path: proto
|
||||||
|
excludes:
|
||||||
|
- proto/idbcompanion
|
||||||
lint:
|
lint:
|
||||||
use:
|
use:
|
||||||
- STANDARD
|
- STANDARD
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ func doctorChecksFor(platform string) []doctorCheck {
|
|||||||
return androidChecks()
|
return androidChecks()
|
||||||
case "ios":
|
case "ios":
|
||||||
return iosChecks()
|
return iosChecks()
|
||||||
|
case "ios-device":
|
||||||
|
return append(iosChecks(), iosDeviceChecks()...)
|
||||||
case "all":
|
case "all":
|
||||||
return allChecks()
|
return allChecks()
|
||||||
default:
|
default:
|
||||||
@@ -55,19 +57,30 @@ 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 java and the sidecar JAR, covered by iosDeviceChecks and
|
||||||
|
// surfaced through the "all" union.
|
||||||
func iosChecks() []doctorCheck {
|
func iosChecks() []doctorCheck {
|
||||||
return []doctorCheck{
|
return []doctorCheck{
|
||||||
{Name: "xcrun on PATH", Run: checkExecutableOnPath("xcrun")},
|
{Name: "xcrun on PATH (ios simulator)", Run: checkExecutableOnPath("xcrun")},
|
||||||
{Name: "simctl on PATH", Run: checkExecutableOnPath("simctl")},
|
{Name: "simctl available (ios simulator)", Run: checkSimctl},
|
||||||
{Name: "java 17+ on PATH", Run: checkJavaVersion},
|
}
|
||||||
{Name: "sidecar JAR is real (not placeholder)", Run: checkSidecarJAR},
|
}
|
||||||
|
|
||||||
|
// iosDeviceChecks covers the extra prerequisites a physical iOS device needs:
|
||||||
|
// the JVM and a real sidecar JAR for the sidecar driver path.
|
||||||
|
func iosDeviceChecks() []doctorCheck {
|
||||||
|
return []doctorCheck{
|
||||||
|
{Name: "java 17+ on PATH (ios physical device)", Run: checkJavaVersion},
|
||||||
|
{Name: "sidecar JAR is real (ios physical device)", Run: checkSidecarJAR},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func allChecks() []doctorCheck {
|
func allChecks() []doctorCheck {
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
var combined []doctorCheck
|
var combined []doctorCheck
|
||||||
for _, group := range [][]doctorCheck{webChecks(), androidChecks(), iosChecks()} {
|
for _, group := range [][]doctorCheck{webChecks(), androidChecks(), iosChecks(), iosDeviceChecks()} {
|
||||||
for _, c := range group {
|
for _, c := range group {
|
||||||
if seen[c.Name] {
|
if seen[c.Name] {
|
||||||
continue
|
continue
|
||||||
@@ -98,6 +111,15 @@ func checkChromiumLaunch(ctx context.Context) error {
|
|||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|
||||||
func checkSidecarJAR(_ context.Context) error {
|
func checkSidecarJAR(_ context.Context) error {
|
||||||
if sidecarassets.IsPlaceholder() {
|
if sidecarassets.IsPlaceholder() {
|
||||||
return fmt.Errorf("placeholder JAR embedded; run `make sidecar && make sanderling` to embed the real fat JAR")
|
return fmt.Errorf("placeholder JAR embedded; run `make sidecar && make sanderling` to embed the real fat JAR")
|
||||||
@@ -116,15 +138,15 @@ func parseDoctorArgs(args []string, stderr io.Writer) (doctorOptions, error) {
|
|||||||
flagSet := flag.NewFlagSet("doctor", flag.ContinueOnError)
|
flagSet := flag.NewFlagSet("doctor", flag.ContinueOnError)
|
||||||
flagSet.SetOutput(stderr)
|
flagSet.SetOutput(stderr)
|
||||||
var options doctorOptions
|
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 {
|
if err := flagSet.Parse(args); err != nil {
|
||||||
return doctorOptions{}, err
|
return doctorOptions{}, err
|
||||||
}
|
}
|
||||||
switch options.platform {
|
switch options.platform {
|
||||||
case "web", "android", "ios", "all":
|
case "web", "android", "ios", "ios-device", "all":
|
||||||
return options, nil
|
return options, nil
|
||||||
default:
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -127,13 +127,33 @@ func TestDoctorChecksFor_All_IsUnion(t *testing.T) {
|
|||||||
for _, c := range all {
|
for _, c := range all {
|
||||||
names[c.Name]++
|
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 {
|
if names[name] != 1 {
|
||||||
t.Errorf("expected %q in 'all' exactly once, got %d", name, names[name])
|
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_IncludesJava(t *testing.T) {
|
||||||
|
found := false
|
||||||
|
for _, c := range doctorChecksFor("ios-device") {
|
||||||
|
if strings.Contains(c.Name, "java") {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("ios-device checks must include java for the sidecar path")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDoctorChecksFor_UnknownPlatform(t *testing.T) {
|
func TestDoctorChecksFor_UnknownPlatform(t *testing.T) {
|
||||||
if got := doctorChecksFor("fuchsia"); got != nil {
|
if got := doctorChecksFor("fuchsia"); got != nil {
|
||||||
t.Errorf("expected nil for unknown platform, got %+v", got)
|
t.Errorf("expected nil for unknown platform, got %+v", got)
|
||||||
|
|||||||
+18
-10
@@ -8,6 +8,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -16,15 +18,16 @@ import (
|
|||||||
var Version = "dev"
|
var Version = "dev"
|
||||||
|
|
||||||
type testOptions struct {
|
type testOptions struct {
|
||||||
spec string
|
spec string
|
||||||
bundleID string
|
bundleID string
|
||||||
platform string
|
platform string
|
||||||
avd string
|
avd string
|
||||||
iosDevice string
|
iosDevice string
|
||||||
duration time.Duration
|
iosAppPath string
|
||||||
seed int64
|
duration time.Duration
|
||||||
output string
|
seed int64
|
||||||
clearData bool
|
output string
|
||||||
|
clearData bool
|
||||||
}
|
}
|
||||||
|
|
||||||
const topUsage = `sanderling is a property-based UI fuzzer for mobile apps.
|
const topUsage = `sanderling is a property-based UI fuzzer for mobile apps.
|
||||||
@@ -50,6 +53,7 @@ func parseTestArgs(args []string, stderr io.Writer) (testOptions, error) {
|
|||||||
flagSet.StringVar(&options.platform, "platform", "android", "target platform: android, ios, web")
|
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.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 simulator name or UDID to boot if none is running")
|
||||||
|
flagSet.StringVar(&options.iosAppPath, "ios-app-path", "", "path to the .app bundle for iOS simulator clear-state reinstall")
|
||||||
flagSet.DurationVar(&options.duration, "duration", 5*time.Minute, "total test duration")
|
flagSet.DurationVar(&options.duration, "duration", 5*time.Minute, "total test duration")
|
||||||
flagSet.Int64Var(&options.seed, "seed", 0, "RNG seed (0 = random)")
|
flagSet.Int64Var(&options.seed, "seed", 0, "RNG seed (0 = random)")
|
||||||
flagSet.StringVar(&options.output, "output", "./runs", "output directory for traces")
|
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 {
|
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()
|
defer cancel()
|
||||||
return runTestPipeline(ctx, options, stdout)
|
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) {
|
func TestRun_TestSubcommand_PipelineErrors(t *testing.T) {
|
||||||
// Web skips host-dependent device boot and bundles first, so a missing spec
|
// Web skips host-dependent device boot and bundles first, so a missing spec
|
||||||
// deterministically surfaces a bundle-resolution error. This proves the
|
// deterministically surfaces a bundle-resolution error. This proves the
|
||||||
|
|||||||
@@ -9,14 +9,15 @@ import (
|
|||||||
|
|
||||||
func runTestPipeline(ctx context.Context, options testOptions, stdout io.Writer) error {
|
func runTestPipeline(ctx context.Context, options testOptions, stdout io.Writer) error {
|
||||||
return testrun.Execute(ctx, testrun.Options{
|
return testrun.Execute(ctx, testrun.Options{
|
||||||
Spec: options.spec,
|
Spec: options.spec,
|
||||||
BundleID: options.bundleID,
|
BundleID: options.bundleID,
|
||||||
Platform: options.platform,
|
Platform: options.platform,
|
||||||
AVD: options.avd,
|
AVD: options.avd,
|
||||||
IosDevice: options.iosDevice,
|
IosDevice: options.iosDevice,
|
||||||
Duration: options.duration,
|
IosAppPath: options.iosAppPath,
|
||||||
Seed: options.seed,
|
Duration: options.duration,
|
||||||
Output: options.output,
|
Seed: options.seed,
|
||||||
ClearData: options.clearData,
|
Output: options.output,
|
||||||
|
ClearData: options.clearData,
|
||||||
}, stdout)
|
}, stdout)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,174 @@
|
|||||||
|
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 "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,67 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
+405
@@ -0,0 +1,405 @@
|
|||||||
|
#!/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
|
||||||
|
# native sidecar; 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"
|
||||||
|
ios_app="${folio_directory}/app/iosApp/build/Build/Products/Debug-iphonesimulator/iosApp.app"
|
||||||
|
|
||||||
|
# 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 XCTest runner java sidecar. Empty output means 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 native sidecar that drives the XCTest runner for physical devices.
|
||||||
|
if pgrep -f "sanderling.*sidecar.jar" >/dev/null 2>&1; then
|
||||||
|
found+="sidecar "
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
printf '%s' "$found"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- run orchestration -----------------------------------------------------
|
||||||
|
|
||||||
|
invoke_sanderling() {
|
||||||
|
local output_directory="$1"
|
||||||
|
local output_log="$2"
|
||||||
|
local exit_status_file="$3"
|
||||||
|
|
||||||
|
local target_flags=()
|
||||||
|
if [[ "$BACKEND" == "device" ]]; then
|
||||||
|
target_flags=(--ios-device "$IOS_DEVICE")
|
||||||
|
else
|
||||||
|
# Simulator: build + install the current app so each run starts from the
|
||||||
|
# current build, matching the test-ios recipe. clear-state reinstall uses
|
||||||
|
# --ios-app-path. just ios boots IOS_DEVICE if nothing is booted.
|
||||||
|
target_flags=(--ios-device "$IOS_DEVICE" --ios-app-path "$ios_app")
|
||||||
|
fi
|
||||||
|
|
||||||
|
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 )
|
||||||
|
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": ""}}]}}
|
||||||
@@ -7,6 +7,7 @@ duration := env_var_or_default("DURATION", "1m")
|
|||||||
seed := env_var_or_default("SEED", "0")
|
seed := env_var_or_default("SEED", "0")
|
||||||
output := env_var_or_default("OUTPUT", justfile_directory() / "sanderling" / "runs")
|
output := env_var_or_default("OUTPUT", justfile_directory() / "sanderling" / "runs")
|
||||||
ios_device := env_var_or_default("IOS_DEVICE", "iPhone 17 Pro")
|
ios_device := env_var_or_default("IOS_DEVICE", "iPhone 17 Pro")
|
||||||
|
ios_app := justfile_directory() / "app" / "iosApp" / "build" / "Build" / "Products" / "Debug-iphonesimulator" / "iosApp.app"
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@just --list
|
@just --list
|
||||||
@@ -118,7 +119,7 @@ ios:
|
|||||||
-destination 'platform=iOS Simulator,name={{ios_device}}' \
|
-destination 'platform=iOS Simulator,name={{ios_device}}' \
|
||||||
-derivedDataPath app/iosApp/build \
|
-derivedDataPath app/iosApp/build \
|
||||||
build | tail -5
|
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
|
xcrun simctl launch booted app.folio
|
||||||
|
|
||||||
# Run 'sanderling test' against the folio app. Uses a connected device if one is
|
# Run 'sanderling test' against the folio app. Uses a connected device if one is
|
||||||
@@ -167,6 +168,7 @@ test-ios:
|
|||||||
--platform ios \
|
--platform ios \
|
||||||
--spec "{{justfile_directory()}}/sanderling/spec.ts" \
|
--spec "{{justfile_directory()}}/sanderling/spec.ts" \
|
||||||
--bundle-id app.folio \
|
--bundle-id app.folio \
|
||||||
|
--ios-app-path "{{ios_app}}" \
|
||||||
"${ios_device_flag[@]}" \
|
"${ios_device_flag[@]}" \
|
||||||
--duration "{{duration}}" \
|
--duration "{{duration}}" \
|
||||||
--seed "{{seed}}" \
|
--seed "{{seed}}" \
|
||||||
|
|||||||
@@ -26,10 +26,10 @@ type seed struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type caseEntry struct {
|
type caseEntry struct {
|
||||||
Seed seed `json:"seed"`
|
Seed seed `json:"seed"`
|
||||||
Uint64 []string `json:"uint64"`
|
Uint64 []string `json:"uint64"`
|
||||||
Float64 []float64 `json:"float64"`
|
Float64 []float64 `json:"float64"`
|
||||||
IntN map[string][]int `json:"intN"`
|
IntN map[string][]int `json:"intN"`
|
||||||
}
|
}
|
||||||
|
|
||||||
const drawCount = 40
|
const drawCount = 40
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
// Package companionassets embeds the simulator companion binary and its
|
||||||
|
// frameworks, and extracts the bundle to disk at runtime.
|
||||||
|
package companionassets
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// companionBinaryName is the file name inside bin/ fixed by the vendored
|
||||||
|
// layout. The binary resolves its frameworks through @rpath relative to this
|
||||||
|
// path, so it must not be renamed.
|
||||||
|
const companionBinaryName = "idb_companion"
|
||||||
|
|
||||||
|
// EmbeddedSize returns the size in bytes of the embedded companion archive.
|
||||||
|
func EmbeddedSize() int { return len(embeddedArchive) }
|
||||||
|
|
||||||
|
// EmbeddedSHA256 returns the hex-encoded SHA-256 of the embedded archive.
|
||||||
|
func EmbeddedSHA256() string {
|
||||||
|
sum := sha256.Sum256(embeddedArchive)
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract unpacks the embedded companion archive into dir, preserving the
|
||||||
|
// bin/ and Frameworks/ layout and any symlinks. A .sha256 marker next to the
|
||||||
|
// extracted tree gates re-extraction: if it already matches, no rewrite
|
||||||
|
// happens. Returns the absolute path to the companion binary.
|
||||||
|
func Extract(dir string) (string, error) {
|
||||||
|
if len(embeddedArchive) == 0 {
|
||||||
|
return "", errors.New("companion: binary built without -tags withcompanion; rebuild with `make sanderling`")
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return "", fmt.Errorf("mkdir %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
binaryPath, err := filepath.Abs(filepath.Join(dir, "bin", companionBinaryName))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
checksumPath := filepath.Join(dir, "companion.sha256")
|
||||||
|
checksum := EmbeddedSHA256()
|
||||||
|
|
||||||
|
if existing, err := os.ReadFile(checksumPath); err == nil && string(existing) == checksum {
|
||||||
|
if _, err := os.Stat(binaryPath); err == nil {
|
||||||
|
return binaryPath, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := unpack(dir); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := os.Chmod(binaryPath, 0o755); err != nil {
|
||||||
|
return "", fmt.Errorf("chmod companion binary: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(checksumPath, []byte(checksum), 0o644); err != nil {
|
||||||
|
return "", fmt.Errorf("write checksum: %w", err)
|
||||||
|
}
|
||||||
|
return binaryPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func unpack(dir string) error {
|
||||||
|
gzipReader, err := gzip.NewReader(bytes.NewReader(embeddedArchive))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open companion archive: %w", err)
|
||||||
|
}
|
||||||
|
defer gzipReader.Close()
|
||||||
|
|
||||||
|
tarReader := tar.NewReader(gzipReader)
|
||||||
|
for {
|
||||||
|
header, err := tarReader.Next()
|
||||||
|
if err == io.EOF {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read companion archive: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(filepath.Base(header.Name), "._") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
target, err := safeJoin(dir, header.Name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch header.Typeflag {
|
||||||
|
case tar.TypeDir:
|
||||||
|
if err := os.MkdirAll(target, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case tar.TypeSymlink:
|
||||||
|
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
os.Remove(target)
|
||||||
|
if err := os.Symlink(header.Linkname, target); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case tar.TypeReg:
|
||||||
|
if err := writeFile(target, tarReader, os.FileMode(header.Mode)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFile(path string, src io.Reader, mode os.FileMode) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
file, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(file, src); err != nil {
|
||||||
|
file.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return file.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// safeJoin rejects archive entries that would escape dir.
|
||||||
|
func safeJoin(dir, name string) (string, error) {
|
||||||
|
target := filepath.Join(dir, name)
|
||||||
|
relative, err := filepath.Rel(dir, target)
|
||||||
|
if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(os.PathSeparator)) {
|
||||||
|
return "", fmt.Errorf("archive entry escapes destination: %s", name)
|
||||||
|
}
|
||||||
|
return target, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
//go:build withcompanion
|
||||||
|
|
||||||
|
package companionassets
|
||||||
|
|
||||||
|
import _ "embed"
|
||||||
|
|
||||||
|
//go:embed assets/companion-1.1.8.tar.gz
|
||||||
|
var embeddedArchive []byte
|
||||||
|
|
||||||
|
// IsPlaceholder reports whether the binary was built without the real
|
||||||
|
// companion archive embedded. -tags withcompanion builds always return false.
|
||||||
|
func IsPlaceholder() bool { return false }
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
//go:build !withcompanion
|
||||||
|
|
||||||
|
package companionassets
|
||||||
|
|
||||||
|
var embeddedArchive []byte
|
||||||
|
|
||||||
|
// IsPlaceholder reports whether the binary was built without the real
|
||||||
|
// companion archive embedded. Build with `make sanderling` (which passes
|
||||||
|
// -tags withcompanion) to embed the real companion bundle.
|
||||||
|
func IsPlaceholder() bool { return true }
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
//go:build !withcompanion
|
||||||
|
|
||||||
|
package companionassets
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStubBuild_IsPlaceholder(t *testing.T) {
|
||||||
|
if !IsPlaceholder() {
|
||||||
|
t.Error("default build (no -tags withcompanion) must report a placeholder")
|
||||||
|
}
|
||||||
|
if EmbeddedSize() != 0 {
|
||||||
|
t.Errorf("placeholder build must embed no archive, got %d bytes", EmbeddedSize())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStubBuild_ExtractErrors(t *testing.T) {
|
||||||
|
_, err := Extract(t.TempDir())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Extract must fail when no archive is embedded")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "withcompanion") {
|
||||||
|
t.Errorf("error should tell the user to rebuild with -tags withcompanion, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
//go:build withcompanion
|
||||||
|
|
||||||
|
package companionassets
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEmbeddedNonZero(t *testing.T) {
|
||||||
|
if EmbeddedSize() == 0 {
|
||||||
|
t.Errorf("expected embedded companion archive to be non-empty")
|
||||||
|
}
|
||||||
|
if IsPlaceholder() {
|
||||||
|
t.Errorf("withcompanion build should not be a placeholder")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmbeddedSHA256Matches(t *testing.T) {
|
||||||
|
sum := sha256.Sum256(embeddedArchive)
|
||||||
|
if hex.EncodeToString(sum[:]) != EmbeddedSHA256() {
|
||||||
|
t.Errorf("EmbeddedSHA256 does not match a fresh hash of the archive")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtract_WritesBinaryAndChecksum(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
path, err := Extract(directory)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
expected, err := filepath.Abs(filepath.Join(directory, "bin", companionBinaryName))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if path != expected {
|
||||||
|
t.Errorf("unexpected binary path: got %s want %s", path, expected)
|
||||||
|
}
|
||||||
|
checksum, err := os.ReadFile(filepath.Join(directory, "companion.sha256"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(checksum) != EmbeddedSHA256() {
|
||||||
|
t.Errorf("checksum file content wrong: %q", checksum)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtract_LayoutAndSymlinks(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
binaryPath, err := Extract(directory)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(binaryPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("companion binary missing: %v", err)
|
||||||
|
}
|
||||||
|
if info.Mode().Perm()&0o111 == 0 {
|
||||||
|
t.Errorf("companion binary is not executable, mode=%v", info.Mode())
|
||||||
|
}
|
||||||
|
|
||||||
|
frameworksDir := filepath.Join(directory, "Frameworks")
|
||||||
|
if stat, err := os.Stat(frameworksDir); err != nil || !stat.IsDir() {
|
||||||
|
t.Fatalf("Frameworks directory missing: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedFrameworks := []string{
|
||||||
|
"FBControlCore.framework",
|
||||||
|
"FBDeviceControl.framework",
|
||||||
|
"FBSimulatorControl.framework",
|
||||||
|
"IDBCompanionUtilities.framework",
|
||||||
|
"IDBGRPCSwift.framework",
|
||||||
|
"XCTestBootstrap.framework",
|
||||||
|
}
|
||||||
|
for _, name := range expectedFrameworks {
|
||||||
|
if stat, err := os.Stat(filepath.Join(frameworksDir, name)); err != nil || !stat.IsDir() {
|
||||||
|
t.Errorf("expected framework %s missing: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Versions/Current symlink must resolve to a real directory, proving
|
||||||
|
// symlinks survived extraction.
|
||||||
|
current := filepath.Join(frameworksDir, "FBControlCore.framework", "Versions", "Current")
|
||||||
|
linkInfo, err := os.Lstat(current)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Versions/Current missing: %v", err)
|
||||||
|
}
|
||||||
|
if linkInfo.Mode()&os.ModeSymlink == 0 {
|
||||||
|
t.Errorf("Versions/Current is not a symlink")
|
||||||
|
}
|
||||||
|
resolved, err := os.Stat(current)
|
||||||
|
if err != nil || !resolved.IsDir() {
|
||||||
|
t.Errorf("Versions/Current does not resolve to a directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A runtime dylib that must be preserved inside FBControlCore Resources.
|
||||||
|
dylib := filepath.Join(frameworksDir, "FBControlCore.framework", "Versions", "A", "Resources", "libMaculator.dylib")
|
||||||
|
if _, err := os.Stat(dylib); err != nil {
|
||||||
|
t.Errorf("expected runtime dylib preserved: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtract_ReusesWhenChecksumMatches(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
binaryPath, err := Extract(directory)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sentinel := []byte("SENTINEL-do-not-rewrite")
|
||||||
|
if err := os.WriteFile(binaryPath, sentinel, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := Extract(directory); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
after, err := os.ReadFile(binaryPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(after) != string(sentinel) {
|
||||||
|
t.Errorf("second extract rewrote the binary; reuse branch should have skipped extraction")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtract_RewritesIfChecksumMissing(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
if _, err := Extract(directory); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
checksumPath := filepath.Join(directory, "companion.sha256")
|
||||||
|
if err := os.Remove(checksumPath); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := Extract(directory); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(checksumPath); err != nil {
|
||||||
|
t.Errorf("checksum should have been rewritten: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+97
@@ -0,0 +1,97 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Builds the embeddable simulator companion tarball.
|
||||||
|
#
|
||||||
|
# Copies the runtime files from a local install, preserving the bin/ and
|
||||||
|
# Frameworks/ layout (the binary resolves frameworks through @rpath, so the
|
||||||
|
# two directories must stay siblings). Build-time metadata that is never
|
||||||
|
# needed at runtime is stripped to keep the embedded payload small. The
|
||||||
|
# stripped layout is re-signed ad-hoc and proven to execute before it is
|
||||||
|
# packaged.
|
||||||
|
#
|
||||||
|
# Output: assets/companion-1.1.8.tar.gz next to this script.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Keep the archiver from emitting AppleDouble (._name) sidecar entries that
|
||||||
|
# would otherwise duplicate extended attributes into the payload.
|
||||||
|
export COPYFILE_DISABLE=1
|
||||||
|
|
||||||
|
VERSION="1.1.8"
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ASSETS_DIR="${SCRIPT_DIR}/assets"
|
||||||
|
OUTPUT="${ASSETS_DIR}/companion-${VERSION}.tar.gz"
|
||||||
|
|
||||||
|
# Default source is the local formula install. Resolve the symlink so the
|
||||||
|
# rsync below copies real files rather than a dangling link.
|
||||||
|
SOURCE="${COMPANION_SOURCE:-/opt/homebrew/opt/idb-companion}"
|
||||||
|
if [ ! -e "${SOURCE}" ]; then
|
||||||
|
echo "source not found: ${SOURCE}" >&2
|
||||||
|
echo "set COMPANION_SOURCE to the install prefix" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
SOURCE="$(cd "${SOURCE}" && pwd -P)"
|
||||||
|
|
||||||
|
if [ ! -x "${SOURCE}/bin/idb_companion" ]; then
|
||||||
|
echo "no companion binary at ${SOURCE}/bin/idb_companion" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
STAGE="$(mktemp -d)"
|
||||||
|
PROOF="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "${STAGE}" "${PROOF}"' EXIT
|
||||||
|
|
||||||
|
echo "staging from ${SOURCE}"
|
||||||
|
|
||||||
|
# Copy the runtime layout. -a preserves symlinks (the macOS framework
|
||||||
|
# Versions/Current links must survive intact).
|
||||||
|
mkdir -p "${STAGE}/bin"
|
||||||
|
cp -a "${SOURCE}/bin/idb_companion" "${STAGE}/bin/idb_companion"
|
||||||
|
cp -aR "${SOURCE}/Frameworks" "${STAGE}/Frameworks"
|
||||||
|
|
||||||
|
# Strip build-time metadata from every framework. The framework binary, its
|
||||||
|
# Info.plist, the code signature, and any runtime dylibs are kept; headers and
|
||||||
|
# the Swift module/source-info artifacts are not loaded at runtime.
|
||||||
|
for framework in "${STAGE}"/Frameworks/*.framework; do
|
||||||
|
version_dir="${framework}/Versions/A"
|
||||||
|
[ -d "${version_dir}" ] || continue
|
||||||
|
|
||||||
|
rm -rf "${version_dir}/Headers" "${version_dir}/PrivateHeaders"
|
||||||
|
rm -rf "${version_dir}/Modules"
|
||||||
|
rm -f "${framework}/Headers" "${framework}/PrivateHeaders" "${framework}/Modules"
|
||||||
|
|
||||||
|
find "${version_dir}" -name '*.swiftmodule' -prune -exec rm -rf {} + 2>/dev/null || true
|
||||||
|
find "${version_dir}" \( -name '*.swiftdoc' -o -name '*.swiftsourceinfo' \) -delete 2>/dev/null || true
|
||||||
|
done
|
||||||
|
|
||||||
|
# Re-sign each framework and the main binary ad-hoc. Removing files breaks the
|
||||||
|
# existing signature seal, which the loader rejects on arm64 macOS.
|
||||||
|
for framework in "${STAGE}"/Frameworks/*.framework; do
|
||||||
|
codesign --force --sign - --timestamp=none "${framework}" >/dev/null 2>&1
|
||||||
|
done
|
||||||
|
codesign --force --sign - --timestamp=none "${STAGE}/bin/idb_companion" >/dev/null 2>&1
|
||||||
|
|
||||||
|
# Prove the stripped, re-signed layout still loads and runs. tar stores
|
||||||
|
# symlinks as symlinks so the extracted copy mirrors the embedded payload.
|
||||||
|
# --version dynamically links every framework and exits zero on success,
|
||||||
|
# which fails if a strip or re-sign broke a load command or the seal.
|
||||||
|
STRIPPED_BYTES="$(find "${STAGE}" -type f -exec stat -f%z {} + | awk '{sum += $1} END {print sum}')"
|
||||||
|
|
||||||
|
PROOF_TAR="${PROOF}/payload.tar.gz"
|
||||||
|
tar -czf "${PROOF_TAR}" -C "${STAGE}" .
|
||||||
|
tar -xzf "${PROOF_TAR}" -C "${PROOF}"
|
||||||
|
|
||||||
|
if ! "${PROOF}/bin/idb_companion" --version >/dev/null 2>&1; then
|
||||||
|
echo "stripped companion failed to execute" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "stripped layout executes"
|
||||||
|
|
||||||
|
mkdir -p "${ASSETS_DIR}"
|
||||||
|
tar -czf "${OUTPUT}" -C "${STAGE}" .
|
||||||
|
|
||||||
|
SHA="$(shasum -a 256 "${OUTPUT}" | awk '{print $1}')"
|
||||||
|
|
||||||
|
echo "wrote ${OUTPUT}"
|
||||||
|
echo "stripped uncompressed size: ${STRIPPED_BYTES} bytes"
|
||||||
|
echo "sha256: ${SHA}"
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,853 @@
|
|||||||
|
package ioscompanion
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/png"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
|
||||||
|
"github.com/priyanshujain/sanderling/internal/driver"
|
||||||
|
"github.com/priyanshujain/sanderling/internal/driver/ioscompanion/transport"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeCompanion is an in-package stand-in for transport.Companion. It records
|
||||||
|
// the order of calls and returns scripted results, so the driver's decision
|
||||||
|
// logic is testable without a live simulator.
|
||||||
|
type fakeCompanion struct {
|
||||||
|
// callsMutex guards calls: Snapshot captures the hierarchy and the
|
||||||
|
// screenshot concurrently.
|
||||||
|
callsMutex sync.Mutex
|
||||||
|
calls []string
|
||||||
|
|
||||||
|
accessibilityJSON string
|
||||||
|
accessibilityErr error
|
||||||
|
screenshotData []byte
|
||||||
|
describe transport.ScreenDescription
|
||||||
|
apps []transport.InstalledApp
|
||||||
|
|
||||||
|
// hidErr fires on the next SendHID then clears, letting a test inject one
|
||||||
|
// transient failure.
|
||||||
|
hidErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeCompanion) record(name string) {
|
||||||
|
f.callsMutex.Lock()
|
||||||
|
defer f.callsMutex.Unlock()
|
||||||
|
f.calls = append(f.calls, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeCompanion) recorded() []string {
|
||||||
|
f.callsMutex.Lock()
|
||||||
|
defer f.callsMutex.Unlock()
|
||||||
|
out := make([]string, len(f.calls))
|
||||||
|
copy(out, f.calls)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeCompanion) AccessibilityInfo(context.Context) (string, error) {
|
||||||
|
f.record("accessibility")
|
||||||
|
return f.accessibilityJSON, f.accessibilityErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeCompanion) Describe(context.Context) (transport.ScreenDescription, error) {
|
||||||
|
f.record("describe")
|
||||||
|
return f.describe, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeCompanion) SendHID(context.Context, ...transport.HIDEvent) error {
|
||||||
|
f.record("hid")
|
||||||
|
if f.hidErr != nil {
|
||||||
|
err := f.hidErr
|
||||||
|
f.hidErr = nil
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeCompanion) Screenshot(context.Context) ([]byte, string, error) {
|
||||||
|
f.record("screenshot")
|
||||||
|
return f.screenshotData, "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeCompanion) Launch(_ context.Context, _ string, _ bool) error {
|
||||||
|
f.record("launch")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeCompanion) Terminate(context.Context, string) error {
|
||||||
|
f.record("terminate")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeCompanion) ListApps(context.Context) ([]transport.InstalledApp, error) {
|
||||||
|
f.record("listapps")
|
||||||
|
return f.apps, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeCompanion) Install(context.Context, string) error {
|
||||||
|
f.record("install")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeCompanion) Uninstall(context.Context, string) error {
|
||||||
|
f.record("uninstall")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeCompanion) Close() error {
|
||||||
|
f.record("close")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ transport.Companion = (*fakeCompanion)(nil)
|
||||||
|
|
||||||
|
// newTestDriver returns a Driver wired to companion with no child process and a
|
||||||
|
// no-op container reset, so tests never touch the filesystem or spawn anything.
|
||||||
|
func newTestDriver(companion transport.Companion) *Driver {
|
||||||
|
d := &Driver{
|
||||||
|
companion: companion,
|
||||||
|
bundleID: "com.example.app",
|
||||||
|
output: &bytes.Buffer{},
|
||||||
|
doubleTapGapMilliseconds: DefaultDoubleTapGapMilliseconds,
|
||||||
|
screenWidth: 390,
|
||||||
|
screenHeight: 844,
|
||||||
|
}
|
||||||
|
d.resetContainer = func(context.Context) error { return nil }
|
||||||
|
d.reinstallApp = func(context.Context) error { return nil }
|
||||||
|
d.grantPaste = func(context.Context) error { return nil }
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func samplePNG(t *testing.T, width, height int) []byte {
|
||||||
|
t.Helper()
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||||
|
img.Set(0, 0, color.RGBA{R: 1, A: 255})
|
||||||
|
var buffer bytes.Buffer
|
||||||
|
if err := png.Encode(&buffer, img); err != nil {
|
||||||
|
t.Fatalf("encode png: %v", err)
|
||||||
|
}
|
||||||
|
return buffer.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLaunchTerminatesFirstThenLaunches(t *testing.T) {
|
||||||
|
companion := &fakeCompanion{accessibilityJSON: "[]"}
|
||||||
|
d := newTestDriver(companion)
|
||||||
|
if err := d.Launch(context.Background(), "", false, nil); err != nil {
|
||||||
|
t.Fatalf("Launch: %v", err)
|
||||||
|
}
|
||||||
|
if companion.calls[0] != "terminate" {
|
||||||
|
t.Fatalf("first call = %q, want terminate", companion.calls[0])
|
||||||
|
}
|
||||||
|
if indexOf(companion.calls, "launch") < indexOf(companion.calls, "terminate") {
|
||||||
|
t.Fatalf("launch must follow terminate; got %v", companion.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLaunchGrantsPasteboardBeforeLaunch(t *testing.T) {
|
||||||
|
companion := &fakeCompanion{accessibilityJSON: "[]"}
|
||||||
|
d := newTestDriver(companion)
|
||||||
|
granted := false
|
||||||
|
d.grantPaste = func(context.Context) error { granted = true; return nil }
|
||||||
|
if err := d.Launch(context.Background(), "", false, nil); err != nil {
|
||||||
|
t.Fatalf("Launch: %v", err)
|
||||||
|
}
|
||||||
|
if !granted {
|
||||||
|
t.Fatal("Launch must grant pasteboard access so unicode input skips the permission prompt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLaunchContinuesWhenGrantFails(t *testing.T) {
|
||||||
|
companion := &fakeCompanion{accessibilityJSON: "[]"}
|
||||||
|
d := newTestDriver(companion)
|
||||||
|
d.grantPaste = func(context.Context) error { return errors.New("no privacy db") }
|
||||||
|
if err := d.Launch(context.Background(), "", false, nil); err != nil {
|
||||||
|
t.Fatalf("Launch must continue when the grant fails: %v", err)
|
||||||
|
}
|
||||||
|
if indexOf(companion.calls, "launch") < 0 {
|
||||||
|
t.Fatalf("app must still launch; got %v", companion.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLaunchClearStateReinstallsWithAppPath(t *testing.T) {
|
||||||
|
companion := &fakeCompanion{accessibilityJSON: "[]"}
|
||||||
|
d := newTestDriver(companion)
|
||||||
|
d.appPath = "/tmp/Sample.app"
|
||||||
|
reinstalls := 0
|
||||||
|
d.reinstallApp = func(context.Context) error { reinstalls++; return nil }
|
||||||
|
if err := d.Launch(context.Background(), "", true, nil); err != nil {
|
||||||
|
t.Fatalf("Launch: %v", err)
|
||||||
|
}
|
||||||
|
if reinstalls != 1 {
|
||||||
|
t.Fatalf("clear-state with app path must reinstall exactly once; got %d", reinstalls)
|
||||||
|
}
|
||||||
|
if indexOf(companion.calls, "launch") < indexOf(companion.calls, "terminate") {
|
||||||
|
t.Fatalf("launch must still follow terminate; got %v", companion.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLaunchClearStateFallbackWarnsOnce(t *testing.T) {
|
||||||
|
companion := &fakeCompanion{accessibilityJSON: "[]"}
|
||||||
|
output := &bytes.Buffer{}
|
||||||
|
d := newTestDriver(companion)
|
||||||
|
d.output = output
|
||||||
|
resets := 0
|
||||||
|
d.resetContainer = func(context.Context) error { resets++; return nil }
|
||||||
|
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
if err := d.Launch(context.Background(), "", true, nil); err != nil {
|
||||||
|
t.Fatalf("Launch %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if resets != 2 {
|
||||||
|
t.Fatalf("resetContainer called %d times, want 2", resets)
|
||||||
|
}
|
||||||
|
warnings := strings.Count(output.String(), "resetting the data container only")
|
||||||
|
if warnings != 1 {
|
||||||
|
t.Fatalf("warning emitted %d times, want once", warnings)
|
||||||
|
}
|
||||||
|
for _, call := range companion.calls {
|
||||||
|
if call == "install" || call == "uninstall" {
|
||||||
|
t.Fatalf("fallback path must not install/uninstall; got %v", companion.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLaunchRejectsEnvironment(t *testing.T) {
|
||||||
|
d := newTestDriver(&fakeCompanion{accessibilityJSON: "[]"})
|
||||||
|
err := d.Launch(context.Background(), "", false, map[string]string{"K": "V"})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "environment") {
|
||||||
|
t.Fatalf("Launch with env: err = %v, want unsupported-environment error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSnapshotPairsHierarchyAndScreenshot(t *testing.T) {
|
||||||
|
companion := &fakeCompanion{
|
||||||
|
accessibilityJSON: "[]",
|
||||||
|
screenshotData: samplePNG(t, 390, 844),
|
||||||
|
}
|
||||||
|
d := newTestDriver(companion)
|
||||||
|
_, image, err := d.Snapshot(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Snapshot: %v", err)
|
||||||
|
}
|
||||||
|
if image.Width != 390 || image.Height != 844 {
|
||||||
|
t.Fatalf("image dims = %dx%d, want 390x844", image.Width, image.Height)
|
||||||
|
}
|
||||||
|
// The two captures run concurrently (they ride different transports on
|
||||||
|
// the hybrid path), so both must happen but in no particular order.
|
||||||
|
calls := companion.recorded()
|
||||||
|
if indexOf(calls, "accessibility") < 0 || indexOf(calls, "screenshot") < 0 {
|
||||||
|
t.Fatalf("snapshot must capture hierarchy and screenshot; got %v", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// blockingScreenshotCompanion holds its Screenshot until proceed closes, so a
|
||||||
|
// test can keep the screenshot leg in flight while the hierarchy leg recovers.
|
||||||
|
type blockingScreenshotCompanion struct {
|
||||||
|
fakeCompanion
|
||||||
|
proceed chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *blockingScreenshotCompanion) Screenshot(ctx context.Context) ([]byte, string, error) {
|
||||||
|
<-b.proceed
|
||||||
|
return b.fakeCompanion.Screenshot(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSnapshotRestartDuringScreenshotDoesNotRace(t *testing.T) {
|
||||||
|
// The hierarchy leg drops its connection, forcing withRecovery to restart
|
||||||
|
// while the screenshot goroutine is still in flight. The restart reassigns
|
||||||
|
// d.companion the way respawnAndRedial does; the goroutine must keep
|
||||||
|
// working through the transport it captured rather than racing the field.
|
||||||
|
first := &blockingScreenshotCompanion{
|
||||||
|
fakeCompanion: fakeCompanion{
|
||||||
|
accessibilityErr: status.Error(codes.Unavailable, "companion gone"),
|
||||||
|
screenshotData: samplePNG(t, 390, 844),
|
||||||
|
},
|
||||||
|
proceed: make(chan struct{}),
|
||||||
|
}
|
||||||
|
replacement := &fakeCompanion{
|
||||||
|
accessibilityJSON: "[]",
|
||||||
|
screenshotData: samplePNG(t, 390, 844),
|
||||||
|
}
|
||||||
|
d := newTestDriver(first)
|
||||||
|
d.restart = func(context.Context) error {
|
||||||
|
d.companion = replacement
|
||||||
|
close(first.proceed)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, image, err := d.Snapshot(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Snapshot should recover: %v", err)
|
||||||
|
}
|
||||||
|
if image.Width != 390 || image.Height != 844 {
|
||||||
|
t.Fatalf("image dims = %dx%d, want 390x844", image.Width, image.Height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScreenshotRejectsNonPNG(t *testing.T) {
|
||||||
|
d := newTestDriver(&fakeCompanion{screenshotData: []byte("not a png")})
|
||||||
|
if _, err := d.Screenshot(context.Background()); err == nil {
|
||||||
|
t.Fatal("Screenshot of non-PNG should error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInputTextFastPathSkipsFieldResolution(t *testing.T) {
|
||||||
|
companion := &fakeCompanion{accessibilityJSON: "[]"}
|
||||||
|
d := newTestDriver(companion)
|
||||||
|
// "abc" is fully mappable and under the paste threshold, so the fast
|
||||||
|
// keyboard path runs and never reads the accessibility dump for a field
|
||||||
|
// target.
|
||||||
|
if err := d.InputText(context.Background(), "abc"); err != nil {
|
||||||
|
t.Fatalf("InputText: %v", err)
|
||||||
|
}
|
||||||
|
if indexOf(companion.calls, "hid") < 0 {
|
||||||
|
t.Fatalf("fast path must send HID; got %v", companion.calls)
|
||||||
|
}
|
||||||
|
if indexOf(companion.calls, "accessibility") >= 0 {
|
||||||
|
t.Fatalf("fast path must not resolve a field via accessibility; got %v", companion.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInputTextPasteboardResolvesFieldFromLastTap(t *testing.T) {
|
||||||
|
// The dump carries one editable field whose frame contains the last tap.
|
||||||
|
dump := `[{"type":"TextField","AXUniqueId":"field-1","frame":{"x":10,"y":100,"width":200,"height":40}}]`
|
||||||
|
companion := &fakeCompanion{accessibilityJSON: dump, screenshotData: samplePNG(t, 10, 10)}
|
||||||
|
d := newTestDriver(companion)
|
||||||
|
// Tap inside the field so lastTap lands on it.
|
||||||
|
if err := d.Tap(context.Background(), 50, 120); err != nil {
|
||||||
|
t.Fatalf("Tap: %v", err)
|
||||||
|
}
|
||||||
|
field := d.resolveInputField(context.Background())
|
||||||
|
if field.identifier != "field-1" {
|
||||||
|
t.Fatalf("resolved field id = %q, want field-1", field.identifier)
|
||||||
|
}
|
||||||
|
if field.centerX != 110 || field.centerY != 120 {
|
||||||
|
t.Fatalf("field center = (%v,%v), want (110,120)", field.centerX, field.centerY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveInputFieldEmptyWhenTapOutsideAnyField(t *testing.T) {
|
||||||
|
dump := `[{"type":"TextField","AXUniqueId":"field-1","frame":{"x":10,"y":100,"width":200,"height":40}}]`
|
||||||
|
d := newTestDriver(&fakeCompanion{accessibilityJSON: dump})
|
||||||
|
if err := d.Tap(context.Background(), 5, 5); err != nil {
|
||||||
|
t.Fatalf("Tap: %v", err)
|
||||||
|
}
|
||||||
|
if field := d.resolveInputField(context.Background()); field != (fieldTarget{}) {
|
||||||
|
t.Fatalf("field = %+v, want empty", field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSupervisionRestartsOnceThenSucceeds(t *testing.T) {
|
||||||
|
companion := &fakeCompanion{
|
||||||
|
accessibilityJSON: "[]",
|
||||||
|
hidErr: status.Error(codes.Unavailable, "connection refused"),
|
||||||
|
}
|
||||||
|
d := newTestDriver(companion)
|
||||||
|
restarts := 0
|
||||||
|
d.restart = func(context.Context) error {
|
||||||
|
restarts++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// First SendHID fails with Unavailable; the recovery restarts once and the
|
||||||
|
// retry succeeds (hidErr cleared itself).
|
||||||
|
if err := d.Tap(context.Background(), 10, 10); err != nil {
|
||||||
|
t.Fatalf("Tap should recover: %v", err)
|
||||||
|
}
|
||||||
|
if restarts != 1 {
|
||||||
|
t.Fatalf("restarts = %d, want exactly 1", restarts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSupervisionDoesNotRestartOnNormalError(t *testing.T) {
|
||||||
|
companion := &fakeCompanion{hidErr: errors.New("bad argument")}
|
||||||
|
d := newTestDriver(companion)
|
||||||
|
restarts := 0
|
||||||
|
d.restart = func(context.Context) error { restarts++; return nil }
|
||||||
|
if err := d.Tap(context.Background(), 10, 10); err == nil {
|
||||||
|
t.Fatal("non-connection error should surface")
|
||||||
|
}
|
||||||
|
if restarts != 0 {
|
||||||
|
t.Fatalf("restarts = %d, want 0 on a normal error", restarts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSupervisionBudgetResetsBetweenIncidents(t *testing.T) {
|
||||||
|
companion := &fakeCompanion{accessibilityJSON: "[]"}
|
||||||
|
d := newTestDriver(companion)
|
||||||
|
restarts := 0
|
||||||
|
d.restart = func(context.Context) error { restarts++; return nil }
|
||||||
|
|
||||||
|
companion.hidErr = status.Error(codes.Unavailable, "drop one")
|
||||||
|
if err := d.Tap(context.Background(), 1, 1); err != nil {
|
||||||
|
t.Fatalf("first incident: %v", err)
|
||||||
|
}
|
||||||
|
companion.hidErr = status.Error(codes.Unavailable, "drop two")
|
||||||
|
if err := d.Tap(context.Background(), 2, 2); err != nil {
|
||||||
|
t.Fatalf("second incident: %v", err)
|
||||||
|
}
|
||||||
|
if restarts != 2 {
|
||||||
|
t.Fatalf("restarts = %d, want 2 (one per incident)", restarts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPressKeyEnterSupportedOthersUnsupported(t *testing.T) {
|
||||||
|
d := newTestDriver(&fakeCompanion{})
|
||||||
|
if err := d.PressKey(context.Background(), "enter"); err != nil {
|
||||||
|
t.Fatalf("PressKey enter: %v", err)
|
||||||
|
}
|
||||||
|
if err := d.PressKey(context.Background(), "return"); err != nil {
|
||||||
|
t.Fatalf("PressKey return: %v", err)
|
||||||
|
}
|
||||||
|
for _, key := range []string{"back", "home", "tab"} {
|
||||||
|
if err := d.PressKey(context.Background(), key); err == nil {
|
||||||
|
t.Fatalf("PressKey %q should be unsupported", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWaitForIdleSettlesOnStableTree(t *testing.T) {
|
||||||
|
companion := &fakeCompanion{accessibilityJSON: "[]"}
|
||||||
|
d := newTestDriver(companion)
|
||||||
|
// A fake clock advances time only on Sleep, so the settle poll runs to its
|
||||||
|
// cap instantly instead of taking the real two seconds.
|
||||||
|
d.idleClock = &fakeClock{}
|
||||||
|
if err := d.WaitForIdle(context.Background(), time.Second); err != nil {
|
||||||
|
t.Fatalf("WaitForIdle: %v", err)
|
||||||
|
}
|
||||||
|
if indexOf(companion.calls, "accessibility") < 0 {
|
||||||
|
t.Fatalf("WaitForIdle must poll the hierarchy; got %v", companion.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestForegroundAppPrefersAppUnderTest(t *testing.T) {
|
||||||
|
companion := &fakeCompanion{apps: []transport.InstalledApp{
|
||||||
|
{BundleID: "com.example.app", ProcessState: transport.ProcessStateRunning, InstallType: "user"},
|
||||||
|
{BundleID: "com.other.app", ProcessState: transport.ProcessStateRunning, InstallType: "user"},
|
||||||
|
}}
|
||||||
|
d := newTestDriver(companion)
|
||||||
|
got, err := d.ForegroundApp(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ForegroundApp: %v", err)
|
||||||
|
}
|
||||||
|
if got != "com.example.app" {
|
||||||
|
t.Fatalf("ForegroundApp = %q, want com.example.app", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestForegroundAppFallsBackToOtherRunningUserApp(t *testing.T) {
|
||||||
|
companion := &fakeCompanion{apps: []transport.InstalledApp{
|
||||||
|
{BundleID: "com.example.app", ProcessState: transport.ProcessStateNotRunning, InstallType: "user"},
|
||||||
|
{BundleID: "com.other.app", ProcessState: transport.ProcessStateRunning, InstallType: "user"},
|
||||||
|
}}
|
||||||
|
d := newTestDriver(companion)
|
||||||
|
got, err := d.ForegroundApp(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ForegroundApp: %v", err)
|
||||||
|
}
|
||||||
|
if got != "com.other.app" {
|
||||||
|
t.Fatalf("ForegroundApp = %q, want com.other.app", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealthReportsIOS(t *testing.T) {
|
||||||
|
d := newTestDriver(&fakeCompanion{})
|
||||||
|
health, err := d.Health(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Health: %v", err)
|
||||||
|
}
|
||||||
|
if !health.Ready || health.Platform != "ios" {
|
||||||
|
t.Fatalf("Health = %+v, want ready ios", health)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The driver must satisfy DeviceDriver, ForegroundChecker, and TextReplacer
|
||||||
|
// (its InputText clears the field then types, so the runner skips its
|
||||||
|
// blackout-fragile pre-erase), but must NOT satisfy FocusedWindowChecker.
|
||||||
|
func TestCapabilityAssertions(t *testing.T) {
|
||||||
|
var instance any = newTestDriver(&fakeCompanion{})
|
||||||
|
if _, ok := instance.(driver.DeviceDriver); !ok {
|
||||||
|
t.Fatal("Driver must implement DeviceDriver")
|
||||||
|
}
|
||||||
|
if _, ok := instance.(driver.ForegroundChecker); !ok {
|
||||||
|
t.Fatal("Driver must implement ForegroundChecker")
|
||||||
|
}
|
||||||
|
replacer, ok := instance.(driver.TextReplacer)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("Driver must implement TextReplacer")
|
||||||
|
}
|
||||||
|
if !replacer.ReplacesTextOnInput() {
|
||||||
|
t.Fatal("ReplacesTextOnInput must report true")
|
||||||
|
}
|
||||||
|
if _, ok := instance.(driver.FocusedWindowChecker); ok {
|
||||||
|
t.Fatal("Driver must NOT implement FocusedWindowChecker")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexOf(slice []string, value string) int {
|
||||||
|
for i, item := range slice {
|
||||||
|
if item == value {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewChildOutlivesStartup proves the companion child is spawned under the
|
||||||
|
// driver-lifetime context, not the startup-scoped one: a startup context that
|
||||||
|
// is canceled when New returns would SIGTERM the child mid-run.
|
||||||
|
func TestNewChildOutlivesStartup(t *testing.T) {
|
||||||
|
// The legacy-only path keeps this test focused on the companion child;
|
||||||
|
// the hybrid default would also demand runner assets.
|
||||||
|
t.Setenv("SANDERLING_SIMULATOR_COMPANION", "legacy")
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer listener.Close()
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
connection, acceptErr := listener.Accept()
|
||||||
|
if acceptErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = connection.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
var spawnContext context.Context
|
||||||
|
options := Options{
|
||||||
|
UniqueDeviceIdentifier: "FAKE-UDID",
|
||||||
|
pickAddress: func() (string, error) { return listener.Addr().String(), nil },
|
||||||
|
spawnChild: func(ctx context.Context, _ string) (*exec.Cmd, error) {
|
||||||
|
spawnContext = ctx
|
||||||
|
return &exec.Cmd{}, nil
|
||||||
|
},
|
||||||
|
dialCompanion: func(string) (transport.Companion, error) {
|
||||||
|
return &fakeCompanion{accessibilityJSON: "[]"}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
d, err := New(context.Background(), options)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-spawnContext.Done():
|
||||||
|
t.Fatal("spawn context canceled after New returned; child would receive SIGTERM mid-run")
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
d.Close()
|
||||||
|
select {
|
||||||
|
case <-spawnContext.Done():
|
||||||
|
default:
|
||||||
|
t.Fatal("spawn context still alive after Close; child lifetime leaks")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeTextEditingCompanion extends fakeCompanion with the optional TextEditor
|
||||||
|
// capability so routing through the native text path is testable.
|
||||||
|
type fakeTextEditingCompanion struct {
|
||||||
|
fakeCompanion
|
||||||
|
|
||||||
|
inputTexts []string
|
||||||
|
eraseCounts []int
|
||||||
|
pressedKeys []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeTextEditingCompanion) InputText(_ context.Context, text string) error {
|
||||||
|
f.record("inputtext")
|
||||||
|
f.inputTexts = append(f.inputTexts, text)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeTextEditingCompanion) EraseText(_ context.Context, characterCount int) error {
|
||||||
|
f.record("erasetext")
|
||||||
|
f.eraseCounts = append(f.eraseCounts, characterCount)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeTextEditingCompanion) PressKey(_ context.Context, key string) error {
|
||||||
|
f.record("presskey")
|
||||||
|
f.pressedKeys = append(f.pressedKeys, key)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ transport.TextEditor = (*fakeTextEditingCompanion)(nil)
|
||||||
|
|
||||||
|
func TestInputTextRoutesThroughTextEditor(t *testing.T) {
|
||||||
|
companion := &fakeTextEditingCompanion{}
|
||||||
|
d := newTestDriver(companion)
|
||||||
|
if err := d.InputText(context.Background(), "héllo 🌟"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(companion.inputTexts) != 1 || companion.inputTexts[0] != "héllo 🌟" {
|
||||||
|
t.Fatalf("inputTexts = %v, want the typed text once", companion.inputTexts)
|
||||||
|
}
|
||||||
|
for _, call := range companion.calls {
|
||||||
|
if call == "hid" {
|
||||||
|
t.Fatal("text editor path must not compose HID streams")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEraseTextRoutesThroughTextEditor(t *testing.T) {
|
||||||
|
companion := &fakeTextEditingCompanion{}
|
||||||
|
d := newTestDriver(companion)
|
||||||
|
if err := d.EraseText(context.Background(), 7); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(companion.eraseCounts) != 1 || companion.eraseCounts[0] != 7 {
|
||||||
|
t.Fatalf("eraseCounts = %v, want [7]", companion.eraseCounts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPressKeyRoutesThroughTextEditor(t *testing.T) {
|
||||||
|
companion := &fakeTextEditingCompanion{}
|
||||||
|
d := newTestDriver(companion)
|
||||||
|
if err := d.PressKey(context.Background(), "enter"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(companion.pressedKeys) != 1 || companion.pressedKeys[0] != "enter" {
|
||||||
|
t.Fatalf("pressedKeys = %v, want [enter]", companion.pressedKeys)
|
||||||
|
}
|
||||||
|
for _, call := range companion.calls {
|
||||||
|
if call == "hid" {
|
||||||
|
t.Fatal("text editor path must not compose HID streams")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsConnectionErrorRecognizesUnavailableSentinel(t *testing.T) {
|
||||||
|
wrapped := errors.Join(errors.New("dial tcp: connection refused"), transport.ErrCompanionUnavailable)
|
||||||
|
if !isConnectionError(wrapped) {
|
||||||
|
t.Fatal("wrapped ErrCompanionUnavailable must count as a connection error")
|
||||||
|
}
|
||||||
|
if isConnectionError(errors.New("ordinary failure")) {
|
||||||
|
t.Fatal("ordinary errors must not count as connection errors")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeRunnerCompanion stands in for the in-simulator runner half of the hybrid:
|
||||||
|
// it serves snapshots and native typing.
|
||||||
|
type fakeRunnerCompanion struct {
|
||||||
|
fakeCompanion
|
||||||
|
|
||||||
|
typed []struct {
|
||||||
|
text string
|
||||||
|
replace bool
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRunnerCompanion) TypeText(_ context.Context, text string, replace bool) error {
|
||||||
|
f.record("typetext")
|
||||||
|
f.typed = append(f.typed, struct {
|
||||||
|
text string
|
||||||
|
replace bool
|
||||||
|
}{text, replace})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ transport.TextTyper = (*fakeRunnerCompanion)(nil)
|
||||||
|
|
||||||
|
func newHybridTestDriver(legacy *fakeCompanion, runner *fakeRunnerCompanion) *Driver {
|
||||||
|
d := newTestDriver(legacy)
|
||||||
|
d.hybrid = true
|
||||||
|
d.runnerClient = runner
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHybridInputTextClearsViaHIDThenTypes(t *testing.T) {
|
||||||
|
legacy := &fakeCompanion{accessibilityJSON: "[]"}
|
||||||
|
runner := &fakeRunnerCompanion{}
|
||||||
|
d := newHybridTestDriver(legacy, runner)
|
||||||
|
|
||||||
|
if err := d.InputText(context.Background(), "héllo 🌟"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(legacy.calls) != 1 || legacy.calls[0] != "hid" {
|
||||||
|
t.Fatalf("legacy calls = %v, want exactly the clear chord", legacy.calls)
|
||||||
|
}
|
||||||
|
if len(runner.typed) != 1 || runner.typed[0].text != "héllo 🌟" || runner.typed[0].replace {
|
||||||
|
t.Fatalf("typed = %+v, want the text appended without replace", runner.typed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHybridSnapshotsComeFromRunner(t *testing.T) {
|
||||||
|
legacy := &fakeCompanion{accessibilityJSON: `[{"type":"Application","frame":{"x":0,"y":0,"width":1,"height":1},"enabled":true}]`}
|
||||||
|
runner := &fakeRunnerCompanion{}
|
||||||
|
runner.accessibilityJSON = `[{"type":"Button","AXUniqueId":"FromRunner","frame":{"x":0,"y":0,"width":10,"height":10},"enabled":true}]`
|
||||||
|
d := newHybridTestDriver(legacy, runner)
|
||||||
|
|
||||||
|
hierarchy, err := d.Hierarchy(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(hierarchy, "FromRunner") {
|
||||||
|
t.Fatalf("hierarchy = %s, want runner content", hierarchy)
|
||||||
|
}
|
||||||
|
for _, call := range legacy.calls {
|
||||||
|
if call == "accessibility" {
|
||||||
|
t.Fatal("hybrid must not read snapshots from the legacy companion")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHybridLaunchSkipsPasteGrant(t *testing.T) {
|
||||||
|
legacy := &fakeCompanion{accessibilityJSON: "[]"}
|
||||||
|
runner := &fakeRunnerCompanion{}
|
||||||
|
d := newHybridTestDriver(legacy, runner)
|
||||||
|
granted := 0
|
||||||
|
d.grantPaste = func(context.Context) error { granted++; return nil }
|
||||||
|
|
||||||
|
if err := d.Launch(context.Background(), "", false, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if granted != 0 {
|
||||||
|
t.Fatalf("grantPaste called %d times, want 0 on the hybrid path", granted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHybridGesturesStayOnLegacyHID(t *testing.T) {
|
||||||
|
legacy := &fakeCompanion{accessibilityJSON: "[]"}
|
||||||
|
runner := &fakeRunnerCompanion{}
|
||||||
|
d := newHybridTestDriver(legacy, runner)
|
||||||
|
|
||||||
|
if err := d.DoubleTap(context.Background(), 10, 20); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(legacy.calls) != 1 || legacy.calls[0] != "hid" {
|
||||||
|
t.Fatalf("legacy calls = %v, want the double-tap HID stream", legacy.calls)
|
||||||
|
}
|
||||||
|
for _, call := range runner.calls {
|
||||||
|
if call == "hid" {
|
||||||
|
t.Fatal("runner must not receive gesture streams")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHybridRunnerErrorTriggersRestart(t *testing.T) {
|
||||||
|
legacy := &fakeCompanion{accessibilityJSON: "[]"}
|
||||||
|
runner := &fakeRunnerCompanion{}
|
||||||
|
runner.accessibilityErr = fmt.Errorf("read snapshot: %w", transport.ErrCompanionUnavailable)
|
||||||
|
d := newHybridTestDriver(legacy, runner)
|
||||||
|
restarts := 0
|
||||||
|
d.restart = func(context.Context) error {
|
||||||
|
restarts++
|
||||||
|
runner.accessibilityErr = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := d.Hierarchy(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if restarts != 1 {
|
||||||
|
t.Fatalf("restarts = %d, want 1", restarts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBindTestRunPortSubstitutesPlaceholder(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
source := filepath.Join(directory, "runner.xctestrun")
|
||||||
|
if err := os.WriteFile(source, []byte("<string>__COMPANION_PORT__</string>"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
bound, err := bindTestRunPort(source, "27999")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if filepath.Dir(bound) != directory {
|
||||||
|
t.Fatalf("bound copy %s must sit next to the original", bound)
|
||||||
|
}
|
||||||
|
content, err := os.ReadFile(bound)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(content) != "<string>27999</string>" {
|
||||||
|
t.Fatalf("bound content = %s", content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBindTestRunPortRejectsMissingPlaceholder(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
source := filepath.Join(directory, "runner.xctestrun")
|
||||||
|
if err := os.WriteFile(source, []byte("<string>fixed</string>"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := bindTestRunPort(source, "27999"); err == nil {
|
||||||
|
t.Fatal("expected an error for a configuration without the placeholder")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHybridMappableTextRidesOneHIDStream(t *testing.T) {
|
||||||
|
legacy := &fakeCompanion{accessibilityJSON: "[]"}
|
||||||
|
runner := &fakeRunnerCompanion{}
|
||||||
|
d := newHybridTestDriver(legacy, runner)
|
||||||
|
|
||||||
|
if err := d.InputText(context.Background(), "Travel 42"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(legacy.recorded()) != 1 || legacy.recorded()[0] != "hid" {
|
||||||
|
t.Fatalf("legacy calls = %v, want one combined chord-and-keystrokes stream", legacy.recorded())
|
||||||
|
}
|
||||||
|
if len(runner.typed) != 0 {
|
||||||
|
t.Fatalf("typed = %+v, want no native typing for mappable text", runner.typed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHybridUnicodeWaitsForClearedFieldBeforeTyping(t *testing.T) {
|
||||||
|
legacy := &fakeCompanion{accessibilityJSON: "[]"}
|
||||||
|
runner := &fakeRunnerCompanion{}
|
||||||
|
// The focused field still shows old content on the first read and is
|
||||||
|
// empty on the second; typing must come after the cleared read.
|
||||||
|
first := `[{"type":"TextField","AXUniqueId":"F","AXValue":"old","frame":{"x":0,"y":0,"width":100,"height":40},"enabled":true}]`
|
||||||
|
second := `[{"type":"TextField","AXUniqueId":"F","AXValue":"","frame":{"x":0,"y":0,"width":100,"height":40},"enabled":true}]`
|
||||||
|
reads := 0
|
||||||
|
d := newHybridTestDriver(legacy, runner)
|
||||||
|
d.mu.Lock()
|
||||||
|
d.lastTap.x, d.lastTap.y, d.lastTap.set = 50, 20, true
|
||||||
|
d.mu.Unlock()
|
||||||
|
// Swap the dump after the first read through a wrapper companion.
|
||||||
|
wrapped := &sequencedDumpCompanion{fakeRunnerCompanion: runner, dumps: []string{first, second}, reads: &reads}
|
||||||
|
d.runnerClient = wrapped
|
||||||
|
|
||||||
|
if err := d.InputText(context.Background(), "héllo 🌟"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if reads < 2 {
|
||||||
|
t.Fatalf("reads = %d, want at least 2 (poll until cleared)", reads)
|
||||||
|
}
|
||||||
|
if len(wrapped.typed) != 1 || wrapped.typed[0].text != "héllo 🌟" || wrapped.typed[0].replace {
|
||||||
|
t.Fatalf("typed = %+v", wrapped.typed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sequencedDumpCompanion serves scripted dumps in order, repeating the last.
|
||||||
|
type sequencedDumpCompanion struct {
|
||||||
|
*fakeRunnerCompanion
|
||||||
|
dumps []string
|
||||||
|
reads *int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sequencedDumpCompanion) AccessibilityInfo(context.Context) (string, error) {
|
||||||
|
index := *s.reads
|
||||||
|
if index >= len(s.dumps) {
|
||||||
|
index = len(s.dumps) - 1
|
||||||
|
}
|
||||||
|
*s.reads++
|
||||||
|
return s.dumps[index], nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package ioscompanion
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"math"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// rawFrame is the simulator companion frame, in points, with float coordinates.
|
||||||
|
type rawFrame struct {
|
||||||
|
X float64 `json:"x"`
|
||||||
|
Y float64 `json:"y"`
|
||||||
|
Width float64 `json:"width"`
|
||||||
|
Height float64 `json:"height"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// emptyFieldValueSentinel is what the accessibility bridge reports as the
|
||||||
|
// AXValue of an empty editable field. It is bridge state, not app content, so
|
||||||
|
// it maps to an empty value rather than surfacing as literal field text.
|
||||||
|
const emptyFieldValueSentinel = "Invalid"
|
||||||
|
|
||||||
|
// dumpIsCollapsed reports whether a flat describe-all dump carries no real UI
|
||||||
|
// content: it is empty or holds only the application shell. The accessibility
|
||||||
|
// bridge briefly returns this state during cold start and screen transitions
|
||||||
|
// before the real tree reappears.
|
||||||
|
func dumpIsCollapsed(dump []byte) bool {
|
||||||
|
elements := decodeDump(dump)
|
||||||
|
for _, element := range elements {
|
||||||
|
if element.Type != "" && element.Type != "Application" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// rawElement is one entry in the flat describe-all dump returned by the
|
||||||
|
// simulator companion. Only the fields the mapper consumes are declared;
|
||||||
|
// unknown fields are ignored.
|
||||||
|
type rawElement struct {
|
||||||
|
Frame rawFrame `json:"frame"`
|
||||||
|
AXUniqueID *string `json:"AXUniqueId"`
|
||||||
|
AXLabel *string `json:"AXLabel"`
|
||||||
|
AXValue *string `json:"AXValue"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// treeNode mirrors the TreeNode JSON the hierarchy package parses. Bool fields
|
||||||
|
// are pointers so absent ones marshal to null and stay absent for the consumer.
|
||||||
|
type treeNode struct {
|
||||||
|
Attributes map[string]string `json:"attributes"`
|
||||||
|
Children []treeNode `json:"children,omitempty"`
|
||||||
|
Clickable *bool `json:"clickable,omitempty"`
|
||||||
|
Enabled *bool `json:"enabled,omitempty"`
|
||||||
|
Editable *bool `json:"editable,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MapHierarchy converts a flat describe-all dump from the simulator companion
|
||||||
|
// into the TreeNode JSON consumed by hierarchy.Parse. The result is a single
|
||||||
|
// synthesized root covering the whole screen with every dump element as a
|
||||||
|
// direct child. Coordinates are points throughout; nothing is scaled.
|
||||||
|
//
|
||||||
|
// The function is total: malformed input yields an empty-but-valid root rather
|
||||||
|
// than an error, and individual malformed elements are skipped.
|
||||||
|
func MapHierarchy(dump []byte, screenWidth, screenHeight int) ([]byte, error) {
|
||||||
|
root := treeNode{
|
||||||
|
Attributes: map[string]string{
|
||||||
|
"bounds": boundsString(0, 0, screenWidth, screenHeight),
|
||||||
|
"class": "Window",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode element-by-element so a single malformed entry (a bad frame, an
|
||||||
|
// out-of-range number) is skipped rather than discarding the whole dump.
|
||||||
|
var rawElements []json.RawMessage
|
||||||
|
if len(dump) > 0 {
|
||||||
|
_ = json.Unmarshal(dump, &rawElements)
|
||||||
|
}
|
||||||
|
for _, raw := range rawElements {
|
||||||
|
var element rawElement
|
||||||
|
if err := json.Unmarshal(raw, &element); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if child, ok := mapElement(&element); ok {
|
||||||
|
root.Children = append(root.Children, child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.Marshal(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapElement(element *rawElement) (treeNode, bool) {
|
||||||
|
if element.Type == "" {
|
||||||
|
return treeNode{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
frame := element.Frame
|
||||||
|
if !finite(frame.X) || !finite(frame.Y) || !finite(frame.Width) || !finite(frame.Height) {
|
||||||
|
return treeNode{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
left := roundCoord(frame.X)
|
||||||
|
top := roundCoord(frame.Y)
|
||||||
|
attributes := map[string]string{
|
||||||
|
"bounds": boundsString(left, top, left+roundCoord(frame.Width), top+roundCoord(frame.Height)),
|
||||||
|
"class": element.Type,
|
||||||
|
}
|
||||||
|
|
||||||
|
if id := stringValue(element.AXUniqueID); id != "" {
|
||||||
|
attributes["identifier"] = id
|
||||||
|
}
|
||||||
|
|
||||||
|
value := stringValue(element.AXValue)
|
||||||
|
if value == emptyFieldValueSentinel {
|
||||||
|
// An empty editable field reads as this sentinel through the bridge;
|
||||||
|
// it is not app content, so treat the field as empty.
|
||||||
|
value = ""
|
||||||
|
}
|
||||||
|
label := stringValue(element.AXLabel)
|
||||||
|
editable := isEditable(element.Type)
|
||||||
|
|
||||||
|
if value != "" {
|
||||||
|
attributes["text"] = value
|
||||||
|
if label != "" {
|
||||||
|
attributes["accessibilityText"] = label
|
||||||
|
}
|
||||||
|
} else if editable && label != "" {
|
||||||
|
// An empty text field surfaces its placeholder as the AXLabel.
|
||||||
|
attributes["hintText"] = label
|
||||||
|
} else if label != "" {
|
||||||
|
attributes["accessibilityText"] = label
|
||||||
|
}
|
||||||
|
|
||||||
|
enabled := element.Enabled
|
||||||
|
node := treeNode{Attributes: attributes, Enabled: &enabled}
|
||||||
|
|
||||||
|
if editable {
|
||||||
|
yes := true
|
||||||
|
node.Editable = &yes
|
||||||
|
}
|
||||||
|
if element.Type == "Button" {
|
||||||
|
yes := true
|
||||||
|
node.Clickable = &yes
|
||||||
|
}
|
||||||
|
|
||||||
|
return node, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func isEditable(elementType string) bool {
|
||||||
|
return elementType == "TextArea" || elementType == "TextField"
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringValue(pointer *string) string {
|
||||||
|
if pointer == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *pointer
|
||||||
|
}
|
||||||
|
|
||||||
|
func finite(value float64) bool {
|
||||||
|
return !math.IsNaN(value) && !math.IsInf(value, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func roundCoord(value float64) int {
|
||||||
|
return int(math.Round(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
func boundsString(left, top, right, bottom int) string {
|
||||||
|
return "[" + strconv.Itoa(left) + "," + strconv.Itoa(top) + "][" +
|
||||||
|
strconv.Itoa(right) + "," + strconv.Itoa(bottom) + "]"
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
package ioscompanion
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/priyanshujain/sanderling/internal/hierarchy"
|
||||||
|
)
|
||||||
|
|
||||||
|
func mapAndParse(t *testing.T, dump []byte, width, height int) *hierarchy.Tree {
|
||||||
|
t.Helper()
|
||||||
|
treeJSON, err := MapHierarchy(dump, width, height)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MapHierarchy: %v", err)
|
||||||
|
}
|
||||||
|
tree, err := hierarchy.Parse(string(treeJSON))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("hierarchy.Parse: %v", err)
|
||||||
|
}
|
||||||
|
return tree
|
||||||
|
}
|
||||||
|
|
||||||
|
func readDump(t *testing.T, name string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
data, err := os.ReadFile(filepath.Join("testdata", name))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read %s: %v", name, err)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGoldenLoginResolvesIdentifier(t *testing.T) {
|
||||||
|
tree := mapAndParse(t, readDump(t, "login-describe.json"), 402, 874)
|
||||||
|
|
||||||
|
element := tree.Find("id:LoginEmail")
|
||||||
|
if element == nil {
|
||||||
|
t.Fatal("id:LoginEmail did not resolve")
|
||||||
|
}
|
||||||
|
if element.ResourceID != "LoginEmail" {
|
||||||
|
t.Fatalf("ResourceID = %q, want LoginEmail", element.ResourceID)
|
||||||
|
}
|
||||||
|
if element.Class != "TextArea" {
|
||||||
|
t.Fatalf("Class = %q, want TextArea", element.Class)
|
||||||
|
}
|
||||||
|
// LoginEmail is an empty field, so its placeholder is the hintText.
|
||||||
|
if element.Attributes["hintText"] != "Email" {
|
||||||
|
t.Fatalf("hintText = %q, want Email", element.Attributes["hintText"])
|
||||||
|
}
|
||||||
|
if !element.Editable {
|
||||||
|
t.Fatal("LoginEmail should be editable")
|
||||||
|
}
|
||||||
|
wantBounds := hierarchy.Bounds{Left: 34, Top: 125, Right: 368, Bottom: 173}
|
||||||
|
if element.Bounds != wantBounds {
|
||||||
|
t.Fatalf("Bounds = %+v, want %+v", element.Bounds, wantBounds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGoldenScopedQueryUsesSpatialFallback(t *testing.T) {
|
||||||
|
tree := mapAndParse(t, readDump(t, "accounts-describe.json"), 402, 874)
|
||||||
|
|
||||||
|
// The Application node spans the full screen but the flat tree gives it no
|
||||||
|
// descendants. A scoped query for content inside it must resolve through the
|
||||||
|
// hierarchy package's spatial-containment fallback.
|
||||||
|
container := tree.FindNode("class:Application")
|
||||||
|
if container == nil {
|
||||||
|
t.Fatal("class:Application did not resolve")
|
||||||
|
}
|
||||||
|
if len(container.Children) != 0 {
|
||||||
|
t.Fatalf("expected a flat tree with no Application descendants, got %d", len(container.Children))
|
||||||
|
}
|
||||||
|
|
||||||
|
scoped := container.Find("desc:Accounts")
|
||||||
|
if scoped == nil {
|
||||||
|
t.Fatal("scoped desc:Accounts did not resolve via spatial fallback")
|
||||||
|
}
|
||||||
|
if scoped.Description != "Accounts" {
|
||||||
|
t.Fatalf("scoped Description = %q, want Accounts", scoped.Description)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Smallest-area ranking should put the compact label ahead of any larger
|
||||||
|
// spatially-contained match.
|
||||||
|
scopedButton := container.Find("id:AddAccountButton")
|
||||||
|
if scopedButton == nil {
|
||||||
|
t.Fatal("scoped id:AddAccountButton did not resolve via spatial fallback")
|
||||||
|
}
|
||||||
|
if scopedButton.ResourceID != "AddAccountButton" {
|
||||||
|
t.Fatalf("scoped ResourceID = %q, want AddAccountButton", scopedButton.ResourceID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSingle(t *testing.T, dump string) *hierarchy.Element {
|
||||||
|
t.Helper()
|
||||||
|
tree := mapAndParse(t, []byte(dump), 400, 800)
|
||||||
|
if len(tree.Elements) < 2 {
|
||||||
|
t.Fatalf("expected root plus one child, got %d elements", len(tree.Elements))
|
||||||
|
}
|
||||||
|
// Element 0 is the synthesized root; element 1 is the mapped child.
|
||||||
|
return tree.Elements[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlaceholderMapsToHintText(t *testing.T) {
|
||||||
|
dump := `[{"type":"TextField","frame":{"x":10,"y":20,"width":100,"height":40},
|
||||||
|
"AXUniqueId":"Search","AXLabel":"Search accounts","AXValue":null,"enabled":true}]`
|
||||||
|
element := parseSingle(t, dump)
|
||||||
|
if element.Attributes["hintText"] != "Search accounts" {
|
||||||
|
t.Fatalf("hintText = %q, want Search accounts", element.Attributes["hintText"])
|
||||||
|
}
|
||||||
|
if element.Attributes["accessibilityText"] != "" {
|
||||||
|
t.Fatalf("accessibilityText = %q, want empty", element.Attributes["accessibilityText"])
|
||||||
|
}
|
||||||
|
if element.Text != "" {
|
||||||
|
t.Fatalf("text = %q, want empty", element.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNonEmptyValueMapsToText(t *testing.T) {
|
||||||
|
dump := `[{"type":"TextField","frame":{"x":0,"y":0,"width":10,"height":10},
|
||||||
|
"AXUniqueId":"Search","AXLabel":"Search accounts","AXValue":"groceries","enabled":true}]`
|
||||||
|
element := parseSingle(t, dump)
|
||||||
|
if element.Text != "groceries" {
|
||||||
|
t.Fatalf("text = %q, want groceries", element.Text)
|
||||||
|
}
|
||||||
|
// With a value present, the label is a real accessibility label, not a hint.
|
||||||
|
if element.Attributes["accessibilityText"] != "Search accounts" {
|
||||||
|
t.Fatalf("accessibilityText = %q, want Search accounts", element.Attributes["accessibilityText"])
|
||||||
|
}
|
||||||
|
if element.Attributes["hintText"] != "" {
|
||||||
|
t.Fatalf("hintText = %q, want empty", element.Attributes["hintText"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEditableAndClickableFlags(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
elementType string
|
||||||
|
wantEditable bool
|
||||||
|
wantClickable bool
|
||||||
|
}{
|
||||||
|
{"TextField", true, false},
|
||||||
|
{"TextArea", true, false},
|
||||||
|
{"Button", false, true},
|
||||||
|
{"StaticText", false, false},
|
||||||
|
}
|
||||||
|
for _, testCase := range cases {
|
||||||
|
dump := `[{"type":"` + testCase.elementType + `","frame":{"x":0,"y":0,"width":10,"height":10},"enabled":true}]`
|
||||||
|
element := parseSingle(t, dump)
|
||||||
|
if element.Editable != testCase.wantEditable {
|
||||||
|
t.Errorf("%s editable = %v, want %v", testCase.elementType, element.Editable, testCase.wantEditable)
|
||||||
|
}
|
||||||
|
if element.Clickable != testCase.wantClickable {
|
||||||
|
t.Errorf("%s clickable = %v, want %v", testCase.elementType, element.Clickable, testCase.wantClickable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnabledMapsToTopLevelBool(t *testing.T) {
|
||||||
|
dump := `[{"type":"Button","frame":{"x":0,"y":0,"width":10,"height":10},"enabled":false}]`
|
||||||
|
element := parseSingle(t, dump)
|
||||||
|
if element.Enabled {
|
||||||
|
t.Fatal("element should be disabled")
|
||||||
|
}
|
||||||
|
if element.Attributes["enabled"] != "false" {
|
||||||
|
t.Fatalf("enabled attr = %q, want false", element.Attributes["enabled"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBoundsArithmetic(t *testing.T) {
|
||||||
|
dump := `[{"type":"Button","frame":{"x":34,"y":125.33333,"width":334,"height":48.00001},"enabled":true}]`
|
||||||
|
element := parseSingle(t, dump)
|
||||||
|
want := hierarchy.Bounds{Left: 34, Top: 125, Right: 368, Bottom: 173}
|
||||||
|
if element.Bounds != want {
|
||||||
|
t.Fatalf("Bounds = %+v, want %+v", element.Bounds, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMalformedElementsSkipped(t *testing.T) {
|
||||||
|
// First element has no type and must be skipped; second has a NaN frame and
|
||||||
|
// must be skipped; third is valid.
|
||||||
|
dump := `[
|
||||||
|
{"frame":{"x":0,"y":0,"width":10,"height":10},"enabled":true},
|
||||||
|
{"type":"Button","frame":{"x":0,"y":0,"width":1e999,"height":10},"enabled":true},
|
||||||
|
{"type":"Button","frame":{"x":0,"y":0,"width":10,"height":10},"AXUniqueId":"OK","enabled":true}
|
||||||
|
]`
|
||||||
|
tree := mapAndParse(t, []byte(dump), 400, 800)
|
||||||
|
// Root plus exactly one valid child.
|
||||||
|
if len(tree.Elements) != 2 {
|
||||||
|
t.Fatalf("expected 2 elements (root + 1 valid), got %d", len(tree.Elements))
|
||||||
|
}
|
||||||
|
if tree.Find("id:OK") == nil {
|
||||||
|
t.Fatal("valid element OK should resolve")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmptyAndGarbageInputAreTotal(t *testing.T) {
|
||||||
|
for _, dump := range [][]byte{nil, {}, []byte("not json"), []byte("{}"), []byte("[]")} {
|
||||||
|
treeJSON, err := MapHierarchy(dump, 100, 200)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MapHierarchy(%q) error: %v", dump, err)
|
||||||
|
}
|
||||||
|
var node treeNode
|
||||||
|
if err := json.Unmarshal(treeJSON, &node); err != nil {
|
||||||
|
t.Fatalf("result not valid TreeNode JSON for %q: %v", dump, err)
|
||||||
|
}
|
||||||
|
if node.Attributes["bounds"] != "[0,0][100,200]" {
|
||||||
|
t.Fatalf("root bounds = %q, want [0,0][100,200]", node.Attributes["bounds"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRootIsFlatWithAllChildren(t *testing.T) {
|
||||||
|
tree := mapAndParse(t, readDump(t, "login-describe.json"), 402, 874)
|
||||||
|
if tree.Root == nil {
|
||||||
|
t.Fatal("nil root")
|
||||||
|
}
|
||||||
|
// The login dump has 10 elements; all map to direct children of the root.
|
||||||
|
if len(tree.Root.Children) != 10 {
|
||||||
|
t.Fatalf("root children = %d, want 10", len(tree.Root.Children))
|
||||||
|
}
|
||||||
|
wantRoot := hierarchy.Bounds{Left: 0, Top: 0, Right: 402, Bottom: 874}
|
||||||
|
if tree.Root.Bounds != wantRoot {
|
||||||
|
t.Fatalf("root bounds = %+v, want %+v", tree.Root.Bounds, wantRoot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMapHierarchySentinelValueMapsAsEmpty(t *testing.T) {
|
||||||
|
dump := `[{"type":"TextField","AXUniqueId":"F","AXLabel":"Email","AXValue":"Invalid","frame":{"x":0,"y":0,"width":10,"height":10},"enabled":true}]`
|
||||||
|
mapped, err := MapHierarchy([]byte(dump), 100, 100)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(mapped), "Invalid") {
|
||||||
|
t.Fatalf("sentinel leaked into mapped tree: %s", mapped)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(mapped), "hintText") {
|
||||||
|
t.Fatalf("empty editable field should map label to hintText: %s", mapped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDumpIsCollapsed(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
dump string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "empty array", dump: `[]`, want: true},
|
||||||
|
{name: "application only", dump: `[{"type":"Application","frame":{"x":0,"y":0,"width":390,"height":844}}]`, want: true},
|
||||||
|
{name: "malformed", dump: `nope`, want: true},
|
||||||
|
{name: "real screen", dump: `[{"type":"Application"},{"type":"TextArea","AXUniqueId":"LoginEmail","frame":{"x":0,"y":0,"width":1,"height":1}}]`, want: false},
|
||||||
|
{name: "single button", dump: `[{"type":"Button","frame":{"x":0,"y":0,"width":1,"height":1}}]`, want: false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
if got := dumpIsCollapsed([]byte(c.dump)); got != c.want {
|
||||||
|
t.Fatalf("got %v, want %v", got, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,434 @@
|
|||||||
|
// Package ioscompanion drives an iOS simulator through the native simulator
|
||||||
|
// companion. This file composes text input and gestures into HID streams and
|
||||||
|
// implements the pasteboard fallback for text that the hardware keyboard
|
||||||
|
// cannot type (anything outside the mappable rune set, such as accented
|
||||||
|
// letters or emoji).
|
||||||
|
package ioscompanion
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/priyanshujain/sanderling/internal/driver/ioscompanion/transport"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultDoubleTapGapMilliseconds is a sensible inter-tap gap for a synthesized
|
||||||
|
// double tap. The driver owns the real default; gesture composers take the gap
|
||||||
|
// as a parameter so the value stays configurable.
|
||||||
|
const DefaultDoubleTapGapMilliseconds = 70
|
||||||
|
|
||||||
|
// pasteVerifyTimeout bounds the whole paste-and-verify loop. Dismissing the
|
||||||
|
// permission dialog blacks out the accessibility bridge for around 2.5s (the
|
||||||
|
// dump collapses to the root element), and the pasted value only becomes
|
||||||
|
// readable once it recovers, so the budget has to outlast that blackout.
|
||||||
|
const pasteVerifyTimeout = 8 * time.Second
|
||||||
|
|
||||||
|
// pastePoll is the interval between describe-all reads while verifying a paste.
|
||||||
|
const pastePoll = 250 * time.Millisecond
|
||||||
|
|
||||||
|
// dialogSettle is the pause after tapping the dialog's allow button and after
|
||||||
|
// refocusing the field, before the paste chord is resent.
|
||||||
|
const dialogSettle = 200 * time.Millisecond
|
||||||
|
|
||||||
|
// runner abstracts the simulator-companion side effects InputText needs so the
|
||||||
|
// decision logic stays testable without a live device. The driver supplies a
|
||||||
|
// real implementation; tests supply a fake.
|
||||||
|
type runner interface {
|
||||||
|
// setPasteboard places text on the simulator pasteboard.
|
||||||
|
setPasteboard(ctx context.Context, text string) error
|
||||||
|
// sendHID sends one HID stream to the companion.
|
||||||
|
sendHID(ctx context.Context, events ...transport.HIDEvent) error
|
||||||
|
// describeAll returns the flat describe-all accessibility dump.
|
||||||
|
describeAll(ctx context.Context) ([]byte, error)
|
||||||
|
// sleep waits, respecting context cancellation.
|
||||||
|
sleep(ctx context.Context, duration time.Duration) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// simctlRunner is the production runner. It shells out to simctl for the
|
||||||
|
// pasteboard and uses the transport companion for everything else.
|
||||||
|
type simctlRunner struct {
|
||||||
|
companion transport.Companion
|
||||||
|
udid string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r simctlRunner) setPasteboard(ctx context.Context, text string) error {
|
||||||
|
command := exec.CommandContext(ctx, "xcrun", "simctl", "pbcopy", r.udid)
|
||||||
|
command.Stdin = strings.NewReader(text)
|
||||||
|
return command.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r simctlRunner) sendHID(ctx context.Context, events ...transport.HIDEvent) error {
|
||||||
|
return r.companion.SendHID(ctx, events...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r simctlRunner) describeAll(ctx context.Context) ([]byte, error) {
|
||||||
|
info, err := r.companion.AccessibilityInfo(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return []byte(info), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r simctlRunner) sleep(ctx context.Context, duration time.Duration) error {
|
||||||
|
timer := time.NewTimer(duration)
|
||||||
|
defer timer.Stop()
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-timer.C:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fieldTarget identifies the focused field for the pasteboard path: its
|
||||||
|
// AXUniqueId (to confirm the paste landed) and its on-screen center (to refocus
|
||||||
|
// after dismissing the permission dialog).
|
||||||
|
type fieldTarget struct {
|
||||||
|
identifier string
|
||||||
|
centerX float64
|
||||||
|
centerY float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// usesPasteboard reports whether text takes the pasteboard path. Only
|
||||||
|
// unmappable runes force it: on this OS generation every external pasteboard
|
||||||
|
// write re-triggers the paste-permission dialog and dismissing it blacks out
|
||||||
|
// the accessibility bridge for seconds, so the hardware keyboard stays the
|
||||||
|
// default for everything it can express.
|
||||||
|
func usesPasteboard(text string) bool {
|
||||||
|
_, skipped := typeString(text)
|
||||||
|
return len(skipped) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// inputText replaces the focused field's content with text. It selects any
|
||||||
|
// existing content and deletes it first, so the result is the typed text alone
|
||||||
|
// regardless of what the field held. Replacing (rather than relying on the
|
||||||
|
// runner's pre-erase) keeps input correct even when the accessibility bridge is
|
||||||
|
// momentarily collapsed and the runner cannot read the field's length. Mappable
|
||||||
|
// text goes through the hardware keyboard in one HID stream; anything else falls
|
||||||
|
// back to the pasteboard. The field target is only consulted on the pasteboard
|
||||||
|
// path.
|
||||||
|
func inputText(ctx context.Context, run runner, text string, field fieldTarget) error {
|
||||||
|
if !usesPasteboard(text) {
|
||||||
|
events := append(clearFieldEvents(), keyPressEvents(typeStringPresses(text))...)
|
||||||
|
return run.sendHID(ctx, events...)
|
||||||
|
}
|
||||||
|
if err := run.sendHID(ctx, clearFieldEvents()...); err != nil {
|
||||||
|
return fmt.Errorf("clear field: %w", err)
|
||||||
|
}
|
||||||
|
return pasteText(ctx, run, text, field)
|
||||||
|
}
|
||||||
|
|
||||||
|
// typeStringPresses is typeString's presses, dropping the skipped runes (the
|
||||||
|
// caller already decided this text is fully mappable).
|
||||||
|
func typeStringPresses(text string) []KeyPress {
|
||||||
|
presses, _ := typeString(text)
|
||||||
|
return presses
|
||||||
|
}
|
||||||
|
|
||||||
|
// selectionApplyDelayMilliseconds is the in-stream pause between the
|
||||||
|
// select-all chord and the deleting backspace. The chord's selection applies
|
||||||
|
// asynchronously in the app; a backspace fired in the same instant deletes
|
||||||
|
// one character at the cursor instead of the selection, which on a full field
|
||||||
|
// silently turns replace into append. Long content needs the most time, and
|
||||||
|
// this pause covers it with margin.
|
||||||
|
const selectionApplyDelayMilliseconds = 150
|
||||||
|
|
||||||
|
// deleteApplyDelayMilliseconds is the in-stream pause after the deleting
|
||||||
|
// backspace, so following keystrokes land in the emptied field.
|
||||||
|
const deleteApplyDelayMilliseconds = 40
|
||||||
|
|
||||||
|
// clearFieldEvents selects the whole field (command+A), waits for the
|
||||||
|
// selection to apply, deletes it, and waits for the delete to apply, so a
|
||||||
|
// following type or paste lands in an empty field. On an already-empty field
|
||||||
|
// the select selects nothing and the delete is a no-op.
|
||||||
|
func clearFieldEvents() []transport.HIDEvent {
|
||||||
|
events := append(selectAllChordEvents(), transport.Delay(selectionApplyDelayMilliseconds))
|
||||||
|
events = append(events, keyPressEvents(backspaces(1))...)
|
||||||
|
return append(events, transport.Delay(deleteApplyDelayMilliseconds))
|
||||||
|
}
|
||||||
|
|
||||||
|
// pasteText copies the full text to the pasteboard, sends the paste chord
|
||||||
|
// once, and polls until the field reflects the text. The chord is re-sent ONLY
|
||||||
|
// after dismissing a permission dialog (the dialog swallowed that paste);
|
||||||
|
// re-sending it on a slow render would paste the text twice. When the field
|
||||||
|
// cannot be verified (no identifier resolved), one chord plus a settle is the
|
||||||
|
// best available behavior.
|
||||||
|
func pasteText(ctx context.Context, run runner, text string, field fieldTarget) error {
|
||||||
|
if err := run.setPasteboard(ctx, text); err != nil {
|
||||||
|
return fmt.Errorf("set pasteboard: %w", err)
|
||||||
|
}
|
||||||
|
if err := run.sendHID(ctx, pasteChordEvents()...); err != nil {
|
||||||
|
return fmt.Errorf("send paste chord: %w", err)
|
||||||
|
}
|
||||||
|
verifiable := field.identifier != ""
|
||||||
|
maxPolls := int(pasteVerifyTimeout / pastePoll)
|
||||||
|
for poll := 0; poll < maxPolls; poll++ {
|
||||||
|
dump, err := run.describeAll(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("describe accessibility: %w", err)
|
||||||
|
}
|
||||||
|
if pasteLanded(dump, field.identifier, text) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if button, found := findAllowPasteButton(dump); found {
|
||||||
|
// The dialog swallowed the paste; dismiss it, refocus, and resend
|
||||||
|
// the chord exactly once. Dismissing blacks out the bridge, so the
|
||||||
|
// landed value only appears on a later poll.
|
||||||
|
if err := run.sendHID(ctx, tapEvents(button.centerX, button.centerY)...); err != nil {
|
||||||
|
return fmt.Errorf("tap allow button: %w", err)
|
||||||
|
}
|
||||||
|
if err := run.sleep(ctx, dialogSettle); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := run.sendHID(ctx, tapEvents(field.centerX, field.centerY)...); err != nil {
|
||||||
|
return fmt.Errorf("refocus field: %w", err)
|
||||||
|
}
|
||||||
|
if err := run.sleep(ctx, dialogSettle); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := run.sendHID(ctx, pasteChordEvents()...); err != nil {
|
||||||
|
return fmt.Errorf("send paste chord: %w", err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !verifiable {
|
||||||
|
// Without a field identifier the paste cannot be confirmed. The
|
||||||
|
// chord went out and no dialog is blocking it, so one settle is the
|
||||||
|
// best available behavior.
|
||||||
|
return run.sleep(ctx, pastePoll)
|
||||||
|
}
|
||||||
|
// Field not yet showing the text: either the bridge is still blacked
|
||||||
|
// out from the dialog or the paste has not rendered. Keep polling until
|
||||||
|
// the value lands or the budget runs out.
|
||||||
|
if err := run.sleep(ctx, pastePoll); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("paste did not land within %s", pasteVerifyTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// eraseBackspaceThreshold is the largest erase still sent as individual
|
||||||
|
// backspaces. Backspaces render progressively on the simulator (tens of
|
||||||
|
// milliseconds per character), so clearing a long field key-by-key leaves the
|
||||||
|
// screen churning long after the HID call returns and races whatever input
|
||||||
|
// follows. Above the threshold the field is cleared atomically instead.
|
||||||
|
const eraseBackspaceThreshold = 3
|
||||||
|
|
||||||
|
// eraseText deletes characterCount characters from the focused field. Small
|
||||||
|
// counts go as backspaces in one HID stream; larger counts clear the whole
|
||||||
|
// field via select-all plus one backspace. The runner asks for the field's
|
||||||
|
// full length when it pre-erases (replace semantics), so treating a large
|
||||||
|
// count as clear-the-field matches its intent while landing in one frame.
|
||||||
|
func eraseText(ctx context.Context, run runner, characterCount int) error {
|
||||||
|
if characterCount <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if characterCount <= eraseBackspaceThreshold {
|
||||||
|
return run.sendHID(ctx, keyPressEvents(backspaces(characterCount))...)
|
||||||
|
}
|
||||||
|
return run.sendHID(ctx, clearFieldEvents()...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// keyPressEvents flattens key presses into a HID event stream. A shifted press
|
||||||
|
// is wrapped with left-shift down before and up after, so the shift modifier is
|
||||||
|
// held only for that key.
|
||||||
|
func keyPressEvents(presses []KeyPress) []transport.HIDEvent {
|
||||||
|
events := make([]transport.HIDEvent, 0, len(presses)*2)
|
||||||
|
for _, press := range presses {
|
||||||
|
if press.Shift {
|
||||||
|
events = append(events, transport.KeyDown(usageLeftShift))
|
||||||
|
}
|
||||||
|
events = append(events, transport.KeyDown(press.Usage), transport.KeyUp(press.Usage))
|
||||||
|
if press.Shift {
|
||||||
|
events = append(events, transport.KeyUp(usageLeftShift))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return events
|
||||||
|
}
|
||||||
|
|
||||||
|
// pasteChordEvents is the command+V chord: command down, V down, V up,
|
||||||
|
// command up.
|
||||||
|
func pasteChordEvents() []transport.HIDEvent {
|
||||||
|
return []transport.HIDEvent{
|
||||||
|
transport.KeyDown(LeftGUI),
|
||||||
|
transport.KeyDown(VKey),
|
||||||
|
transport.KeyUp(VKey),
|
||||||
|
transport.KeyUp(LeftGUI),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// selectAllChordEvents is the command+A chord selecting the focused field's
|
||||||
|
// whole content.
|
||||||
|
func selectAllChordEvents() []transport.HIDEvent {
|
||||||
|
return []transport.HIDEvent{
|
||||||
|
transport.KeyDown(LeftGUI),
|
||||||
|
transport.KeyDown(usageA),
|
||||||
|
transport.KeyUp(usageA),
|
||||||
|
transport.KeyUp(LeftGUI),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tapEvents is a single tap: finger down then up at one point.
|
||||||
|
func tapEvents(x, y float64) []transport.HIDEvent {
|
||||||
|
return []transport.HIDEvent{transport.TouchDown(x, y), transport.TouchUp(x, y)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// doubleTapEvents is two taps in one stream separated by gapMilliseconds.
|
||||||
|
func doubleTapEvents(x, y float64, gapMilliseconds float64) []transport.HIDEvent {
|
||||||
|
return []transport.HIDEvent{
|
||||||
|
transport.TouchDown(x, y), transport.TouchUp(x, y),
|
||||||
|
transport.Delay(gapMilliseconds),
|
||||||
|
transport.TouchDown(x, y), transport.TouchUp(x, y),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// longPressEvents is a finger held down for holdMilliseconds before lifting.
|
||||||
|
func longPressEvents(x, y float64, holdMilliseconds float64) []transport.HIDEvent {
|
||||||
|
return []transport.HIDEvent{
|
||||||
|
transport.TouchDown(x, y),
|
||||||
|
transport.Delay(holdMilliseconds),
|
||||||
|
transport.TouchUp(x, y),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// allowPasteButton is the located allow button of the paste-permission dialog.
|
||||||
|
type allowPasteButton struct {
|
||||||
|
centerX float64
|
||||||
|
centerY float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// allowPasteLabels are the known en-US labels of the paste dialog's accept
|
||||||
|
// button. iOS also surfaces a reject button ("Don't Allow Paste"), so a plain
|
||||||
|
// "Allow" substring match would be ambiguous; the located labels are matched
|
||||||
|
// exactly against the trimmed AXLabel.
|
||||||
|
var allowPasteLabels = []string{"Allow Paste", "Allow"}
|
||||||
|
|
||||||
|
// findAllowPasteButton locates the allow button of the paste-permission dialog
|
||||||
|
// in a describe-all dump. It first matches a button whose label is a known
|
||||||
|
// allow label. Failing that, it applies a conservative fallback: if exactly one
|
||||||
|
// enabled button is present in the dump, that lone button is taken to be the
|
||||||
|
// dialog's allow control. The fallback is deliberately narrow so it cannot fire
|
||||||
|
// on an ordinary screen full of buttons; the dialog is modal and collapses the
|
||||||
|
// dump to its own controls.
|
||||||
|
func findAllowPasteButton(dump []byte) (allowPasteButton, bool) {
|
||||||
|
elements := decodeDump(dump)
|
||||||
|
|
||||||
|
for _, element := range elements {
|
||||||
|
if element.Type != "Button" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
label := strings.TrimSpace(stringValue(element.AXLabel))
|
||||||
|
if isRejectPasteLabel(label) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, allow := range allowPasteLabels {
|
||||||
|
if label == allow {
|
||||||
|
if center, ok := buttonCenter(element); ok {
|
||||||
|
return center, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var soleEnabled allowPasteButton
|
||||||
|
enabledButtons := 0
|
||||||
|
for _, element := range elements {
|
||||||
|
if element.Type != "Button" || !element.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if isRejectPasteLabel(strings.TrimSpace(stringValue(element.AXLabel))) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
center, ok := buttonCenter(element)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
enabledButtons++
|
||||||
|
soleEnabled = center
|
||||||
|
}
|
||||||
|
if enabledButtons == 1 {
|
||||||
|
return soleEnabled, true
|
||||||
|
}
|
||||||
|
return allowPasteButton{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// isRejectPasteLabel reports whether label is the dialog's reject button. iOS
|
||||||
|
// renders the apostrophe as a right single quotation mark (U+2019), so both the
|
||||||
|
// curly and straight forms are checked.
|
||||||
|
func isRejectPasteLabel(label string) bool {
|
||||||
|
return strings.Contains(label, "Don’t Allow Paste") ||
|
||||||
|
strings.Contains(label, "Don't Allow Paste")
|
||||||
|
}
|
||||||
|
|
||||||
|
func buttonCenter(element rawElement) (allowPasteButton, bool) {
|
||||||
|
frame := element.Frame
|
||||||
|
if !finite(frame.X) || !finite(frame.Y) || !finite(frame.Width) || !finite(frame.Height) {
|
||||||
|
return allowPasteButton{}, false
|
||||||
|
}
|
||||||
|
if frame.Width == 0 && frame.Height == 0 {
|
||||||
|
return allowPasteButton{}, false
|
||||||
|
}
|
||||||
|
return allowPasteButton{
|
||||||
|
centerX: frame.X + frame.Width/2,
|
||||||
|
centerY: frame.Y + frame.Height/2,
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// pasteLanded reports whether the field identified by fieldIdentifier now shows
|
||||||
|
// expectedText in its AXValue. The paste appends at the cursor, so a substring
|
||||||
|
// match (rather than equality) is used: the field may already hold text. A
|
||||||
|
// secure field masks its value as bullets, making content verification
|
||||||
|
// impossible; a non-empty all-bullet value counts as landed.
|
||||||
|
func pasteLanded(dump []byte, fieldIdentifier, expectedText string) bool {
|
||||||
|
if fieldIdentifier == "" || expectedText == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, element := range decodeDump(dump) {
|
||||||
|
if stringValue(element.AXUniqueID) != fieldIdentifier {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
value := stringValue(element.AXValue)
|
||||||
|
if strings.Contains(value, expectedText) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return isMaskedValue(value)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// isMaskedValue reports whether value is a secure field's masked content:
|
||||||
|
// non-empty and made up entirely of bullet characters.
|
||||||
|
func isMaskedValue(value string) bool {
|
||||||
|
if value == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range value {
|
||||||
|
if r != '•' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeDump parses a flat describe-all dump into elements, reusing the same
|
||||||
|
// element shape and per-element tolerance as the hierarchy mapper: a single
|
||||||
|
// malformed entry is skipped rather than discarding the whole dump.
|
||||||
|
func decodeDump(dump []byte) []rawElement {
|
||||||
|
var rawElements []json.RawMessage
|
||||||
|
if len(dump) > 0 {
|
||||||
|
_ = json.Unmarshal(dump, &rawElements)
|
||||||
|
}
|
||||||
|
elements := make([]rawElement, 0, len(rawElements))
|
||||||
|
for _, raw := range rawElements {
|
||||||
|
var element rawElement
|
||||||
|
if err := json.Unmarshal(raw, &element); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
elements = append(elements, element)
|
||||||
|
}
|
||||||
|
return elements
|
||||||
|
}
|
||||||
@@ -0,0 +1,433 @@
|
|||||||
|
package ioscompanion
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/priyanshujain/sanderling/internal/driver/ioscompanion/transport"
|
||||||
|
)
|
||||||
|
|
||||||
|
func eventsEqual(t *testing.T, got, want []transport.HIDEvent) {
|
||||||
|
t.Helper()
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("event count: got %d, want %d", len(got), len(want))
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if !reflect.DeepEqual(got[i], want[i]) {
|
||||||
|
t.Fatalf("event %d differs", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKeyPressEvents(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
presses []KeyPress
|
||||||
|
want []transport.HIDEvent
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "lowercase letter is down then up, no shift",
|
||||||
|
presses: []KeyPress{{Usage: usageA}},
|
||||||
|
want: []transport.HIDEvent{transport.KeyDown(usageA), transport.KeyUp(usageA)},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "shifted letter wraps with left shift down and up",
|
||||||
|
presses: []KeyPress{{Usage: usageA, Shift: true}},
|
||||||
|
want: []transport.HIDEvent{
|
||||||
|
transport.KeyDown(usageLeftShift),
|
||||||
|
transport.KeyDown(usageA),
|
||||||
|
transport.KeyUp(usageA),
|
||||||
|
transport.KeyUp(usageLeftShift),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty input yields no events",
|
||||||
|
presses: nil,
|
||||||
|
want: []transport.HIDEvent{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
eventsEqual(t, keyPressEvents(test.presses), test.want)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKeyPressEventsMixedString(t *testing.T) {
|
||||||
|
presses, skipped := typeString("aB")
|
||||||
|
if len(skipped) != 0 {
|
||||||
|
t.Fatalf("unexpected skipped runes: %v", skipped)
|
||||||
|
}
|
||||||
|
got := keyPressEvents(presses)
|
||||||
|
want := []transport.HIDEvent{
|
||||||
|
transport.KeyDown(usageA), transport.KeyUp(usageA),
|
||||||
|
transport.KeyDown(usageLeftShift),
|
||||||
|
transport.KeyDown(usageA + 1),
|
||||||
|
transport.KeyUp(usageA + 1),
|
||||||
|
transport.KeyUp(usageLeftShift),
|
||||||
|
}
|
||||||
|
eventsEqual(t, got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPasteChordEvents(t *testing.T) {
|
||||||
|
want := []transport.HIDEvent{
|
||||||
|
transport.KeyDown(LeftGUI),
|
||||||
|
transport.KeyDown(VKey),
|
||||||
|
transport.KeyUp(VKey),
|
||||||
|
transport.KeyUp(LeftGUI),
|
||||||
|
}
|
||||||
|
eventsEqual(t, pasteChordEvents(), want)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTapEvents(t *testing.T) {
|
||||||
|
want := []transport.HIDEvent{transport.TouchDown(12, 34), transport.TouchUp(12, 34)}
|
||||||
|
eventsEqual(t, tapEvents(12, 34), want)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoubleTapEvents(t *testing.T) {
|
||||||
|
want := []transport.HIDEvent{
|
||||||
|
transport.TouchDown(5, 6), transport.TouchUp(5, 6),
|
||||||
|
transport.Delay(70),
|
||||||
|
transport.TouchDown(5, 6), transport.TouchUp(5, 6),
|
||||||
|
}
|
||||||
|
eventsEqual(t, doubleTapEvents(5, 6, DefaultDoubleTapGapMilliseconds), want)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLongPressEvents(t *testing.T) {
|
||||||
|
want := []transport.HIDEvent{
|
||||||
|
transport.TouchDown(8, 9),
|
||||||
|
transport.Delay(500),
|
||||||
|
transport.TouchUp(8, 9),
|
||||||
|
}
|
||||||
|
eventsEqual(t, longPressEvents(8, 9, 500), want)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadDialogDump(t *testing.T) []byte {
|
||||||
|
t.Helper()
|
||||||
|
dump, err := os.ReadFile(filepath.Join("testdata", "paste-dialog.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read testdata: %v", err)
|
||||||
|
}
|
||||||
|
return dump
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindAllowPasteButton(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
dump string
|
||||||
|
wantFound bool
|
||||||
|
wantX float64
|
||||||
|
wantY float64
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "exact Allow Paste wins over Don't Allow Paste",
|
||||||
|
dump: string(loadDialogDump(t)),
|
||||||
|
wantFound: true,
|
||||||
|
wantX: 280, // 210 + 140/2
|
||||||
|
wantY: 465, // 440 + 50/2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "plain Allow label matches",
|
||||||
|
dump: `[{"type":"Button","AXLabel":"Allow","frame":{"x":100,"y":100,"width":40,"height":20},"enabled":true}]`,
|
||||||
|
wantFound: true,
|
||||||
|
wantX: 120,
|
||||||
|
wantY: 110,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sole enabled button fallback",
|
||||||
|
dump: `[{"type":"StaticText","AXLabel":"Paste?","frame":{"x":0,"y":0,"width":10,"height":10},"enabled":true},{"type":"Button","AXLabel":"OK","frame":{"x":50,"y":60,"width":100,"height":40},"enabled":true}]`,
|
||||||
|
wantFound: true,
|
||||||
|
wantX: 100,
|
||||||
|
wantY: 80,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no fallback when several enabled buttons present",
|
||||||
|
dump: `[{"type":"Button","AXLabel":"A","frame":{"x":0,"y":0,"width":10,"height":10},"enabled":true},{"type":"Button","AXLabel":"B","frame":{"x":20,"y":0,"width":10,"height":10},"enabled":true}]`,
|
||||||
|
wantFound: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "reject button alone does not match",
|
||||||
|
dump: `[{"type":"Button","AXLabel":"Don’t Allow Paste","frame":{"x":0,"y":0,"width":10,"height":10},"enabled":true}]`,
|
||||||
|
wantFound: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty dump finds nothing",
|
||||||
|
dump: ``,
|
||||||
|
wantFound: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "malformed json finds nothing",
|
||||||
|
dump: `not json`,
|
||||||
|
wantFound: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
button, found := findAllowPasteButton([]byte(test.dump))
|
||||||
|
if found != test.wantFound {
|
||||||
|
t.Fatalf("found: got %v, want %v", found, test.wantFound)
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if button.centerX != test.wantX || button.centerY != test.wantY {
|
||||||
|
t.Fatalf("center: got (%v,%v), want (%v,%v)", button.centerX, button.centerY, test.wantX, test.wantY)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPasteLanded(t *testing.T) {
|
||||||
|
dump := `[{"type":"TextField","AXUniqueId":"NoteField","AXValue":"prefix Café ☕","frame":{"x":0,"y":0,"width":10,"height":10}}]`
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
dump string
|
||||||
|
identifier string
|
||||||
|
expected string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "substring present", dump: dump, identifier: "NoteField", expected: "Café ☕", want: true},
|
||||||
|
{name: "value not yet landed", dump: `[{"type":"TextField","AXUniqueId":"NoteField","AXValue":"prefix"}]`, identifier: "NoteField", expected: "Café", want: false},
|
||||||
|
{name: "field absent", dump: dump, identifier: "MissingField", expected: "Café", want: false},
|
||||||
|
{name: "empty identifier never matches", dump: dump, identifier: "", expected: "Café", want: false},
|
||||||
|
{name: "empty expected never matches", dump: dump, identifier: "NoteField", expected: "", want: false},
|
||||||
|
{name: "malformed dump never matches", dump: `nope`, identifier: "NoteField", expected: "Café", want: false},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
if got := pasteLanded([]byte(test.dump), test.identifier, test.expected); got != test.want {
|
||||||
|
t.Fatalf("got %v, want %v", got, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeRunner records side effects and serves scripted describe-all dumps.
|
||||||
|
type fakeRunner struct {
|
||||||
|
pasteboard string
|
||||||
|
hidStreams [][]transport.HIDEvent
|
||||||
|
dumps [][]byte
|
||||||
|
dumpIndex int
|
||||||
|
setError error
|
||||||
|
sendError error
|
||||||
|
describeErr error
|
||||||
|
sleepCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRunner) setPasteboard(ctx context.Context, text string) error {
|
||||||
|
if f.setError != nil {
|
||||||
|
return f.setError
|
||||||
|
}
|
||||||
|
f.pasteboard = text
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRunner) sendHID(ctx context.Context, events ...transport.HIDEvent) error {
|
||||||
|
if f.sendError != nil {
|
||||||
|
return f.sendError
|
||||||
|
}
|
||||||
|
f.hidStreams = append(f.hidStreams, events)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRunner) describeAll(ctx context.Context) ([]byte, error) {
|
||||||
|
if f.describeErr != nil {
|
||||||
|
return nil, f.describeErr
|
||||||
|
}
|
||||||
|
if f.dumpIndex >= len(f.dumps) {
|
||||||
|
return f.dumps[len(f.dumps)-1], nil
|
||||||
|
}
|
||||||
|
dump := f.dumps[f.dumpIndex]
|
||||||
|
f.dumpIndex++
|
||||||
|
return dump, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRunner) sleep(ctx context.Context, duration time.Duration) error {
|
||||||
|
f.sleepCount++
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInputTextFastPathUsesHardwareKeyboard(t *testing.T) {
|
||||||
|
fake := &fakeRunner{}
|
||||||
|
if err := inputText(context.Background(), fake, "hi", fieldTarget{}); err != nil {
|
||||||
|
t.Fatalf("inputText: %v", err)
|
||||||
|
}
|
||||||
|
if fake.pasteboard != "" {
|
||||||
|
t.Fatalf("fast path must not touch pasteboard, got %q", fake.pasteboard)
|
||||||
|
}
|
||||||
|
if len(fake.hidStreams) != 1 {
|
||||||
|
t.Fatalf("fast path must send one HID stream, got %d", len(fake.hidStreams))
|
||||||
|
}
|
||||||
|
// One stream: clear the field, then type the runes.
|
||||||
|
want := append(clearFieldEvents(),
|
||||||
|
keyPressEvents([]KeyPress{{Usage: usageA + ('h' - 'a')}, {Usage: usageA + ('i' - 'a')}})...)
|
||||||
|
eventsEqual(t, fake.hidStreams[0], want)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInputTextPasteLandsImmediately(t *testing.T) {
|
||||||
|
landed := `[{"type":"TextField","AXUniqueId":"F","AXValue":"Café"}]`
|
||||||
|
fake := &fakeRunner{dumps: [][]byte{[]byte(landed)}}
|
||||||
|
field := fieldTarget{identifier: "F", centerX: 100, centerY: 200}
|
||||||
|
if err := inputText(context.Background(), fake, "Café", field); err != nil {
|
||||||
|
t.Fatalf("inputText: %v", err)
|
||||||
|
}
|
||||||
|
if fake.pasteboard != "Café" {
|
||||||
|
t.Fatalf("pasteboard: got %q", fake.pasteboard)
|
||||||
|
}
|
||||||
|
// Streams: clear field, then paste chord.
|
||||||
|
if len(fake.hidStreams) != 2 {
|
||||||
|
t.Fatalf("expected clear then paste chord, got %d streams", len(fake.hidStreams))
|
||||||
|
}
|
||||||
|
eventsEqual(t, fake.hidStreams[0], clearFieldEvents())
|
||||||
|
eventsEqual(t, fake.hidStreams[1], pasteChordEvents())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInputTextPasteDismissesDialogThenLands(t *testing.T) {
|
||||||
|
dialog := string(loadDialogDump(t))
|
||||||
|
landed := `[{"type":"TextField","AXUniqueId":"TxnNoteField","AXValue":"Café ☕ 😀"}]`
|
||||||
|
// First check sees the dialog (which swallowed the initial chord); the
|
||||||
|
// check after the retried chord sees the landed value.
|
||||||
|
fake := &fakeRunner{dumps: [][]byte{[]byte(dialog), []byte(landed)}}
|
||||||
|
field := fieldTarget{identifier: "TxnNoteField", centerX: 195, centerY: 222}
|
||||||
|
if err := inputText(context.Background(), fake, "Café ☕ 😀", field); err != nil {
|
||||||
|
t.Fatalf("inputText: %v", err)
|
||||||
|
}
|
||||||
|
// Streams: clear field, paste chord, tap allow, refocus field, paste chord again.
|
||||||
|
if len(fake.hidStreams) != 5 {
|
||||||
|
t.Fatalf("expected 5 HID streams, got %d", len(fake.hidStreams))
|
||||||
|
}
|
||||||
|
eventsEqual(t, fake.hidStreams[0], clearFieldEvents())
|
||||||
|
eventsEqual(t, fake.hidStreams[1], pasteChordEvents())
|
||||||
|
eventsEqual(t, fake.hidStreams[2], tapEvents(280, 465))
|
||||||
|
eventsEqual(t, fake.hidStreams[3], tapEvents(195, 222))
|
||||||
|
eventsEqual(t, fake.hidStreams[4], pasteChordEvents())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInputTextPasteFailsAfterAllAttempts(t *testing.T) {
|
||||||
|
stuck := `[{"type":"TextField","AXUniqueId":"F","AXValue":""}]`
|
||||||
|
fake := &fakeRunner{dumps: [][]byte{[]byte(stuck)}}
|
||||||
|
field := fieldTarget{identifier: "F", centerX: 1, centerY: 2}
|
||||||
|
err := inputText(context.Background(), fake, "😀", field)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error after exhausting the verify budget")
|
||||||
|
}
|
||||||
|
// Streams: clear field, then exactly one paste chord (no dialog, so it is
|
||||||
|
// not re-sent: re-sending on a slow render would paste twice).
|
||||||
|
if len(fake.hidStreams) != 2 {
|
||||||
|
t.Fatalf("expected clear then one paste chord, got %d streams", len(fake.hidStreams))
|
||||||
|
}
|
||||||
|
eventsEqual(t, fake.hidStreams[0], clearFieldEvents())
|
||||||
|
wantPolls := int(pasteVerifyTimeout / pastePoll)
|
||||||
|
if fake.sleepCount != wantPolls {
|
||||||
|
t.Fatalf("expected %d verify polls, got %d", wantPolls, fake.sleepCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInputTextPasteLandsAfterBridgeBlackout(t *testing.T) {
|
||||||
|
// The field is absent (bridge blacked out by the dialog) for several
|
||||||
|
// polls, then the value lands. The loop must keep waiting through the
|
||||||
|
// blackout instead of failing.
|
||||||
|
blacked := `[{"type":"Application"}]`
|
||||||
|
landed := `[{"type":"TextField","AXUniqueId":"F","AXValue":"😀"}]`
|
||||||
|
dumps := [][]byte{[]byte(blacked), []byte(blacked), []byte(blacked), []byte(landed)}
|
||||||
|
fake := &fakeRunner{dumps: dumps}
|
||||||
|
field := fieldTarget{identifier: "F", centerX: 1, centerY: 2}
|
||||||
|
if err := inputText(context.Background(), fake, "😀", field); err != nil {
|
||||||
|
t.Fatalf("inputText: %v", err)
|
||||||
|
}
|
||||||
|
// Clear field, one chord, no dialog seen, success once the value appears.
|
||||||
|
if len(fake.hidStreams) != 2 {
|
||||||
|
t.Fatalf("expected clear then one paste chord, got %d streams", len(fake.hidStreams))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInputTextPasteUnverifiableFieldSingleChord(t *testing.T) {
|
||||||
|
// No field identifier resolved: one chord, no dialog, success after the
|
||||||
|
// settle without endless retries.
|
||||||
|
noField := `[{"type":"StaticText","AXLabel":"whatever"}]`
|
||||||
|
fake := &fakeRunner{dumps: [][]byte{[]byte(noField)}}
|
||||||
|
if err := inputText(context.Background(), fake, "😀", fieldTarget{}); err != nil {
|
||||||
|
t.Fatalf("inputText: %v", err)
|
||||||
|
}
|
||||||
|
// Clear field, then one paste chord.
|
||||||
|
if len(fake.hidStreams) != 2 {
|
||||||
|
t.Fatalf("expected clear then one paste chord, got %d streams", len(fake.hidStreams))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEraseTextLargeCountClearsAtomically(t *testing.T) {
|
||||||
|
fake := &fakeRunner{}
|
||||||
|
if err := eraseText(context.Background(), fake, 40); err != nil {
|
||||||
|
t.Fatalf("eraseText: %v", err)
|
||||||
|
}
|
||||||
|
if len(fake.hidStreams) != 1 {
|
||||||
|
t.Fatalf("expected one stream, got %d", len(fake.hidStreams))
|
||||||
|
}
|
||||||
|
eventsEqual(t, fake.hidStreams[0], clearFieldEvents())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEraseTextSendsBackspaces(t *testing.T) {
|
||||||
|
fake := &fakeRunner{}
|
||||||
|
if err := eraseText(context.Background(), fake, 3); err != nil {
|
||||||
|
t.Fatalf("eraseText: %v", err)
|
||||||
|
}
|
||||||
|
if len(fake.hidStreams) != 1 {
|
||||||
|
t.Fatalf("expected one stream, got %d", len(fake.hidStreams))
|
||||||
|
}
|
||||||
|
want := keyPressEvents(backspaces(3))
|
||||||
|
eventsEqual(t, fake.hidStreams[0], want)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEraseTextZeroIsNoOp(t *testing.T) {
|
||||||
|
fake := &fakeRunner{}
|
||||||
|
if err := eraseText(context.Background(), fake, 0); err != nil {
|
||||||
|
t.Fatalf("eraseText: %v", err)
|
||||||
|
}
|
||||||
|
if len(fake.hidStreams) != 0 {
|
||||||
|
t.Fatalf("zero count must send nothing, got %d streams", len(fake.hidStreams))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUsesPasteboard(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
text string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"", false},
|
||||||
|
{"a", false},
|
||||||
|
{"abc", false},
|
||||||
|
{"a long but fully mappable ascii string", false},
|
||||||
|
{"-1", false},
|
||||||
|
{"%s%n", false},
|
||||||
|
{"😀", true},
|
||||||
|
{"Café", true},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := usesPasteboard(c.text); got != c.want {
|
||||||
|
t.Errorf("usesPasteboard(%q) = %v, want %v", c.text, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPasteLandedMaskedSecureField(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
dump string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "all bullets counts as landed", dump: `[{"type":"TextField","AXUniqueId":"PW","AXValue":"•••••"}]`, want: true},
|
||||||
|
{name: "empty secure field not landed", dump: `[{"type":"TextField","AXUniqueId":"PW","AXValue":""}]`, want: false},
|
||||||
|
{name: "mixed bullets and text not masked", dump: `[{"type":"TextField","AXUniqueId":"PW","AXValue":"••a"}]`, want: false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
if got := pasteLanded([]byte(c.dump), "PW", "secret9"); got != c.want {
|
||||||
|
t.Fatalf("got %v, want %v", got, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
// Package ioscompanion talks to the simulator companion to drive an iOS
|
||||||
|
// simulator. This file is pure data: it maps runes to USB HID keyboard
|
||||||
|
// usage IDs. A later module turns these key presses into HID events.
|
||||||
|
package ioscompanion
|
||||||
|
|
||||||
|
// USB HID keyboard usage IDs.
|
||||||
|
const (
|
||||||
|
usageA = 4
|
||||||
|
usage1 = 30
|
||||||
|
usage0 = 39
|
||||||
|
usageReturn = 40
|
||||||
|
usageTab = 43
|
||||||
|
usageSpace = 44
|
||||||
|
usageBackspace = 42
|
||||||
|
usageMinus = 45
|
||||||
|
usageEqual = 46
|
||||||
|
usageLeftBracket = 47
|
||||||
|
usageRightBracket = 48
|
||||||
|
usageBackslash = 49
|
||||||
|
usageSemicolon = 51
|
||||||
|
usageApostrophe = 52
|
||||||
|
usageGrave = 53
|
||||||
|
usageComma = 54
|
||||||
|
usagePeriod = 55
|
||||||
|
usageSlash = 56
|
||||||
|
|
||||||
|
usageLeftShift = 225
|
||||||
|
|
||||||
|
// LeftGUI is the command key. VKey is the letter V. Together they form
|
||||||
|
// the paste chord (command+V).
|
||||||
|
LeftGUI = 227
|
||||||
|
VKey = 25
|
||||||
|
)
|
||||||
|
|
||||||
|
// KeyPress is a single key with whether the shift modifier is held.
|
||||||
|
type KeyPress struct {
|
||||||
|
Usage uint32
|
||||||
|
Shift bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// shiftedSymbols maps a shifted-symbol rune to the unshifted key it lives on.
|
||||||
|
var shiftedSymbols = map[rune]uint32{
|
||||||
|
'!': usage1,
|
||||||
|
'@': usage1 + 1,
|
||||||
|
'#': usage1 + 2,
|
||||||
|
'$': usage1 + 3,
|
||||||
|
'%': usage1 + 4,
|
||||||
|
'^': usage1 + 5,
|
||||||
|
'&': usage1 + 6,
|
||||||
|
'*': usage1 + 7,
|
||||||
|
'(': usage1 + 8,
|
||||||
|
')': usage0,
|
||||||
|
'_': usageMinus,
|
||||||
|
'+': usageEqual,
|
||||||
|
'{': usageLeftBracket,
|
||||||
|
'}': usageRightBracket,
|
||||||
|
'|': usageBackslash,
|
||||||
|
':': usageSemicolon,
|
||||||
|
'"': usageApostrophe,
|
||||||
|
'~': usageGrave,
|
||||||
|
'<': usageComma,
|
||||||
|
'>': usagePeriod,
|
||||||
|
'?': usageSlash,
|
||||||
|
}
|
||||||
|
|
||||||
|
// unshiftedSymbols maps a symbol rune typed without shift to its key.
|
||||||
|
var unshiftedSymbols = map[rune]uint32{
|
||||||
|
'-': usageMinus,
|
||||||
|
'=': usageEqual,
|
||||||
|
'[': usageLeftBracket,
|
||||||
|
']': usageRightBracket,
|
||||||
|
'\\': usageBackslash,
|
||||||
|
';': usageSemicolon,
|
||||||
|
'\'': usageApostrophe,
|
||||||
|
'`': usageGrave,
|
||||||
|
',': usageComma,
|
||||||
|
'.': usagePeriod,
|
||||||
|
'/': usageSlash,
|
||||||
|
}
|
||||||
|
|
||||||
|
// charKey returns the key press for a rune and whether the rune is mappable.
|
||||||
|
func charKey(r rune) (KeyPress, bool) {
|
||||||
|
switch {
|
||||||
|
case r >= 'a' && r <= 'z':
|
||||||
|
return KeyPress{Usage: uint32(usageA + (r - 'a'))}, true
|
||||||
|
case r >= 'A' && r <= 'Z':
|
||||||
|
return KeyPress{Usage: uint32(usageA + (r - 'A')), Shift: true}, true
|
||||||
|
case r >= '1' && r <= '9':
|
||||||
|
return KeyPress{Usage: uint32(usage1 + (r - '1'))}, true
|
||||||
|
case r == '0':
|
||||||
|
return KeyPress{Usage: usage0}, true
|
||||||
|
case r == ' ':
|
||||||
|
return KeyPress{Usage: usageSpace}, true
|
||||||
|
case r == '\n':
|
||||||
|
return KeyPress{Usage: usageReturn}, true
|
||||||
|
case r == '\t':
|
||||||
|
return KeyPress{Usage: usageTab}, true
|
||||||
|
}
|
||||||
|
if usage, ok := unshiftedSymbols[r]; ok {
|
||||||
|
return KeyPress{Usage: usage}, true
|
||||||
|
}
|
||||||
|
if usage, ok := shiftedSymbols[r]; ok {
|
||||||
|
return KeyPress{Usage: usage, Shift: true}, true
|
||||||
|
}
|
||||||
|
return KeyPress{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// typeString returns the ordered key presses for every mappable rune in s.
|
||||||
|
// Unmappable runes are collected in order in skipped.
|
||||||
|
func typeString(s string) (presses []KeyPress, skipped []rune) {
|
||||||
|
for _, r := range s {
|
||||||
|
press, ok := charKey(r)
|
||||||
|
if !ok {
|
||||||
|
skipped = append(skipped, r)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
presses = append(presses, press)
|
||||||
|
}
|
||||||
|
return presses, skipped
|
||||||
|
}
|
||||||
|
|
||||||
|
// backspaces returns n backspace key presses.
|
||||||
|
func backspaces(n int) []KeyPress {
|
||||||
|
presses := make([]KeyPress, 0, n)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
presses = append(presses, KeyPress{Usage: usageBackspace})
|
||||||
|
}
|
||||||
|
return presses
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
package ioscompanion
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCharKeyLetters(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
r rune
|
||||||
|
usage uint32
|
||||||
|
shift bool
|
||||||
|
}{
|
||||||
|
{'a', usageA, false},
|
||||||
|
{'z', usageA + 25, false},
|
||||||
|
{'m', usageA + 12, false},
|
||||||
|
{'A', usageA, true},
|
||||||
|
{'Z', usageA + 25, true},
|
||||||
|
{'M', usageA + 12, true},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
press, ok := charKey(c.r)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("charKey(%q) reported unmappable", c.r)
|
||||||
|
}
|
||||||
|
if press.Usage != c.usage || press.Shift != c.shift {
|
||||||
|
t.Errorf("charKey(%q) = {%d,%v}, want {%d,%v}", c.r, press.Usage, press.Shift, c.usage, c.shift)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCharKeyDigits(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
r rune
|
||||||
|
usage uint32
|
||||||
|
}{
|
||||||
|
{'1', usage1},
|
||||||
|
{'2', usage1 + 1},
|
||||||
|
{'3', usage1 + 2},
|
||||||
|
{'4', usage1 + 3},
|
||||||
|
{'5', usage1 + 4},
|
||||||
|
{'6', usage1 + 5},
|
||||||
|
{'7', usage1 + 6},
|
||||||
|
{'8', usage1 + 7},
|
||||||
|
{'9', usage1 + 8},
|
||||||
|
{'0', usage0},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
press, ok := charKey(c.r)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("charKey(%q) reported unmappable", c.r)
|
||||||
|
}
|
||||||
|
if press.Usage != c.usage || press.Shift {
|
||||||
|
t.Errorf("charKey(%q) = {%d,%v}, want {%d,false}", c.r, press.Usage, press.Shift, c.usage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCharKeyWhitespace(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
r rune
|
||||||
|
usage uint32
|
||||||
|
}{
|
||||||
|
{' ', usageSpace},
|
||||||
|
{'\n', usageReturn},
|
||||||
|
{'\t', usageTab},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
press, ok := charKey(c.r)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("charKey(%q) reported unmappable", c.r)
|
||||||
|
}
|
||||||
|
if press.Usage != c.usage || press.Shift {
|
||||||
|
t.Errorf("charKey(%q) = {%d,%v}, want {%d,false}", c.r, press.Usage, press.Shift, c.usage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCharKeyUnshiftedSymbols(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
r rune
|
||||||
|
usage uint32
|
||||||
|
}{
|
||||||
|
{'-', usageMinus},
|
||||||
|
{'=', usageEqual},
|
||||||
|
{'[', usageLeftBracket},
|
||||||
|
{']', usageRightBracket},
|
||||||
|
{'\\', usageBackslash},
|
||||||
|
{';', usageSemicolon},
|
||||||
|
{'\'', usageApostrophe},
|
||||||
|
{'`', usageGrave},
|
||||||
|
{',', usageComma},
|
||||||
|
{'.', usagePeriod},
|
||||||
|
{'/', usageSlash},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
press, ok := charKey(c.r)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("charKey(%q) reported unmappable", c.r)
|
||||||
|
}
|
||||||
|
if press.Usage != c.usage || press.Shift {
|
||||||
|
t.Errorf("charKey(%q) = {%d,%v}, want {%d,false}", c.r, press.Usage, press.Shift, c.usage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCharKeyShiftedSymbols(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
r rune
|
||||||
|
usage uint32
|
||||||
|
}{
|
||||||
|
{'!', usage1},
|
||||||
|
{'@', usage1 + 1},
|
||||||
|
{'#', usage1 + 2},
|
||||||
|
{'$', usage1 + 3},
|
||||||
|
{'%', usage1 + 4},
|
||||||
|
{'^', usage1 + 5},
|
||||||
|
{'&', usage1 + 6},
|
||||||
|
{'*', usage1 + 7},
|
||||||
|
{'(', usage1 + 8},
|
||||||
|
{')', usage0},
|
||||||
|
{'_', usageMinus},
|
||||||
|
{'+', usageEqual},
|
||||||
|
{'{', usageLeftBracket},
|
||||||
|
{'}', usageRightBracket},
|
||||||
|
{'|', usageBackslash},
|
||||||
|
{':', usageSemicolon},
|
||||||
|
{'"', usageApostrophe},
|
||||||
|
{'~', usageGrave},
|
||||||
|
{'<', usageComma},
|
||||||
|
{'>', usagePeriod},
|
||||||
|
{'?', usageSlash},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
press, ok := charKey(c.r)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("charKey(%q) reported unmappable", c.r)
|
||||||
|
}
|
||||||
|
if press.Usage != c.usage || !press.Shift {
|
||||||
|
t.Errorf("charKey(%q) = {%d,%v}, want {%d,true}", c.r, press.Usage, press.Shift, c.usage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCharKeyUnmappable(t *testing.T) {
|
||||||
|
for _, r := range []rune{'é', '世', '🙂', '\x00'} {
|
||||||
|
if press, ok := charKey(r); ok {
|
||||||
|
t.Errorf("charKey(%q) = {%d,%v}, want unmappable", r, press.Usage, press.Shift)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTypeString(t *testing.T) {
|
||||||
|
presses, skipped := typeString("Ab1!")
|
||||||
|
want := []KeyPress{
|
||||||
|
{Usage: usageA, Shift: true},
|
||||||
|
{Usage: usageA + 1},
|
||||||
|
{Usage: usage1},
|
||||||
|
{Usage: usage1, Shift: true},
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(presses, want) {
|
||||||
|
t.Errorf("presses = %+v, want %+v", presses, want)
|
||||||
|
}
|
||||||
|
if len(skipped) != 0 {
|
||||||
|
t.Errorf("skipped = %v, want none", skipped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTypeStringSkipsUnmappableInOrder(t *testing.T) {
|
||||||
|
presses, skipped := typeString("café 🙂x")
|
||||||
|
wantPresses := []KeyPress{
|
||||||
|
{Usage: usageA + 2},
|
||||||
|
{Usage: usageA},
|
||||||
|
{Usage: usageA + 5},
|
||||||
|
{Usage: usageSpace},
|
||||||
|
{Usage: usageA + 23},
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(presses, wantPresses) {
|
||||||
|
t.Errorf("presses = %+v, want %+v", presses, wantPresses)
|
||||||
|
}
|
||||||
|
wantSkipped := []rune{'é', '🙂'}
|
||||||
|
if !reflect.DeepEqual(skipped, wantSkipped) {
|
||||||
|
t.Errorf("skipped = %q, want %q", skipped, wantSkipped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackspaces(t *testing.T) {
|
||||||
|
if got := backspaces(0); len(got) != 0 {
|
||||||
|
t.Errorf("backspaces(0) = %v, want empty", got)
|
||||||
|
}
|
||||||
|
got := backspaces(3)
|
||||||
|
want := []KeyPress{
|
||||||
|
{Usage: usageBackspace},
|
||||||
|
{Usage: usageBackspace},
|
||||||
|
{Usage: usageBackspace},
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("backspaces(3) = %+v, want %+v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPasteChordConstants(t *testing.T) {
|
||||||
|
if LeftGUI != 227 {
|
||||||
|
t.Errorf("LeftGUI = %d, want 227", LeftGUI)
|
||||||
|
}
|
||||||
|
if VKey != 25 {
|
||||||
|
t.Errorf("VKey = %d, want 25", VKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
// Package runnerassets embeds the in-simulator runner test bundle and its
|
||||||
|
// xctestrun, and extracts them to disk at runtime.
|
||||||
|
package runnerassets
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// xctestrunName is the file name the staged test root uses for the runner's
|
||||||
|
// xctestrun. The driver substitutes the port placeholder in this file before
|
||||||
|
// launching, so it must not be renamed.
|
||||||
|
const xctestrunName = "runner.xctestrun"
|
||||||
|
|
||||||
|
// EmbeddedSize returns the size in bytes of the embedded runner archive.
|
||||||
|
func EmbeddedSize() int { return len(embeddedArchive) }
|
||||||
|
|
||||||
|
// EmbeddedSHA256 returns the hex-encoded SHA-256 of the embedded archive.
|
||||||
|
func EmbeddedSHA256() string {
|
||||||
|
sum := sha256.Sum256(embeddedArchive)
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract unpacks the embedded runner archive into dir, preserving the test
|
||||||
|
// root layout and any symlinks. A .sha256 marker next to the extracted tree
|
||||||
|
// gates re-extraction: if it already matches, no rewrite happens. Returns the
|
||||||
|
// absolute path to the extracted runner.xctestrun.
|
||||||
|
func Extract(dir string) (string, error) {
|
||||||
|
if len(embeddedArchive) == 0 {
|
||||||
|
return "", errors.New("runner: binary built without -tags withcompanion; rebuild with `make sanderling`")
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return "", fmt.Errorf("mkdir %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
xctestrunPath, err := filepath.Abs(filepath.Join(dir, xctestrunName))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
checksumPath := filepath.Join(dir, "runner.sha256")
|
||||||
|
checksum := EmbeddedSHA256()
|
||||||
|
|
||||||
|
if existing, err := os.ReadFile(checksumPath); err == nil && string(existing) == checksum {
|
||||||
|
if _, err := os.Stat(xctestrunPath); err == nil {
|
||||||
|
return xctestrunPath, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := unpack(dir); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(xctestrunPath); err != nil {
|
||||||
|
return "", fmt.Errorf("runner: xctestrun missing after extraction: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(checksumPath, []byte(checksum), 0o644); err != nil {
|
||||||
|
return "", fmt.Errorf("write checksum: %w", err)
|
||||||
|
}
|
||||||
|
return xctestrunPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func unpack(dir string) error {
|
||||||
|
gzipReader, err := gzip.NewReader(bytes.NewReader(embeddedArchive))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open runner archive: %w", err)
|
||||||
|
}
|
||||||
|
defer gzipReader.Close()
|
||||||
|
|
||||||
|
tarReader := tar.NewReader(gzipReader)
|
||||||
|
for {
|
||||||
|
header, err := tarReader.Next()
|
||||||
|
if err == io.EOF {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read runner archive: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(filepath.Base(header.Name), "._") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
target, err := safeJoin(dir, header.Name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch header.Typeflag {
|
||||||
|
case tar.TypeDir:
|
||||||
|
if err := os.MkdirAll(target, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case tar.TypeSymlink:
|
||||||
|
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
os.Remove(target)
|
||||||
|
if err := os.Symlink(header.Linkname, target); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case tar.TypeReg:
|
||||||
|
if err := writeFile(target, tarReader, os.FileMode(header.Mode)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFile(path string, src io.Reader, mode os.FileMode) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
file, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(file, src); err != nil {
|
||||||
|
file.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return file.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// safeJoin rejects archive entries that would escape dir.
|
||||||
|
func safeJoin(dir, name string) (string, error) {
|
||||||
|
target := filepath.Join(dir, name)
|
||||||
|
relative, err := filepath.Rel(dir, target)
|
||||||
|
if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(os.PathSeparator)) {
|
||||||
|
return "", fmt.Errorf("archive entry escapes destination: %s", name)
|
||||||
|
}
|
||||||
|
return target, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
//go:build withcompanion
|
||||||
|
|
||||||
|
package runnerassets
|
||||||
|
|
||||||
|
import _ "embed"
|
||||||
|
|
||||||
|
//go:embed assets/runner-1.0.0.tar.gz
|
||||||
|
var embeddedArchive []byte
|
||||||
|
|
||||||
|
// IsPlaceholder reports whether the binary was built without the real runner
|
||||||
|
// archive embedded. -tags withcompanion builds always return false.
|
||||||
|
func IsPlaceholder() bool { return false }
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
//go:build !withcompanion
|
||||||
|
|
||||||
|
package runnerassets
|
||||||
|
|
||||||
|
var embeddedArchive []byte
|
||||||
|
|
||||||
|
// IsPlaceholder reports whether the binary was built without the real runner
|
||||||
|
// archive embedded. Build with `make sanderling` (which passes
|
||||||
|
// -tags withcompanion) to embed the real runner test bundle.
|
||||||
|
func IsPlaceholder() bool { return true }
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
//go:build !withcompanion
|
||||||
|
|
||||||
|
package runnerassets
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStubBuild_IsPlaceholder(t *testing.T) {
|
||||||
|
if !IsPlaceholder() {
|
||||||
|
t.Error("default build (no -tags withcompanion) must report a placeholder")
|
||||||
|
}
|
||||||
|
if EmbeddedSize() != 0 {
|
||||||
|
t.Errorf("placeholder build must embed no archive, got %d bytes", EmbeddedSize())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStubBuild_ExtractErrors(t *testing.T) {
|
||||||
|
_, err := Extract(t.TempDir())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Extract must fail when no archive is embedded")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "withcompanion") {
|
||||||
|
t.Errorf("error should tell the user to rebuild with -tags withcompanion, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
//go:build withcompanion
|
||||||
|
|
||||||
|
package runnerassets
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEmbeddedNonZero(t *testing.T) {
|
||||||
|
if EmbeddedSize() == 0 {
|
||||||
|
t.Errorf("expected embedded runner archive to be non-empty")
|
||||||
|
}
|
||||||
|
if IsPlaceholder() {
|
||||||
|
t.Errorf("withcompanion build should not be a placeholder")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmbeddedSHA256Matches(t *testing.T) {
|
||||||
|
sum := sha256.Sum256(embeddedArchive)
|
||||||
|
if hex.EncodeToString(sum[:]) != EmbeddedSHA256() {
|
||||||
|
t.Errorf("EmbeddedSHA256 does not match a fresh hash of the archive")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtract_WritesXctestrunAndChecksum(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
path, err := Extract(directory)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
expected, err := filepath.Abs(filepath.Join(directory, xctestrunName))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if path != expected {
|
||||||
|
t.Errorf("unexpected xctestrun path: got %s want %s", path, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
contents, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(contents), "__COMPANION_PORT__") {
|
||||||
|
t.Errorf("extracted xctestrun is missing the __COMPANION_PORT__ placeholder")
|
||||||
|
}
|
||||||
|
|
||||||
|
checksum, err := os.ReadFile(filepath.Join(directory, "runner.sha256"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(checksum) != EmbeddedSHA256() {
|
||||||
|
t.Errorf("checksum file content wrong: %q", checksum)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtract_Layout(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
if _, err := Extract(directory); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
runnerApp := filepath.Join(directory, "Debug-iphonesimulator", "CompanionRunnerUITests-Runner.app")
|
||||||
|
if stat, err := os.Stat(runnerApp); err != nil || !stat.IsDir() {
|
||||||
|
t.Fatalf("runner app directory missing: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
binary := filepath.Join(runnerApp, "CompanionRunnerUITests-Runner")
|
||||||
|
if _, err := os.Stat(binary); err != nil {
|
||||||
|
t.Errorf("runner app binary missing: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtract_SymlinksResolve(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
if _, err := Extract(directory); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulator bundles are flat, so the runner app may contain no symlinks at
|
||||||
|
// all. Any symlink that is present must resolve to a real target, proving
|
||||||
|
// the unpack path restores links rather than copying broken stubs.
|
||||||
|
root := filepath.Join(directory, "Debug-iphonesimulator")
|
||||||
|
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if info.Mode()&os.ModeSymlink != 0 {
|
||||||
|
if _, statErr := os.Stat(path); statErr != nil {
|
||||||
|
t.Errorf("symlink %s does not resolve: %v", path, statErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtract_ReusesWhenChecksumMatches(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
xctestrunPath, err := Extract(directory)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sentinel := []byte("SENTINEL-do-not-rewrite")
|
||||||
|
if err := os.WriteFile(xctestrunPath, sentinel, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := Extract(directory); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
after, err := os.ReadFile(xctestrunPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(after) != string(sentinel) {
|
||||||
|
t.Errorf("second extract rewrote the xctestrun; reuse branch should have skipped extraction")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtract_RewritesIfChecksumMissing(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
if _, err := Extract(directory); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
checksumPath := filepath.Join(directory, "runner.sha256")
|
||||||
|
if err := os.Remove(checksumPath); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := Extract(directory); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(checksumPath); err != nil {
|
||||||
|
t.Errorf("checksum should have been rewritten: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package ioscompanion
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/priyanshujain/sanderling/internal/driver/ioscompanion/runnerassets"
|
||||||
|
)
|
||||||
|
|
||||||
|
// runnerPortPlaceholder is the marker the prepare script plants in the
|
||||||
|
// packaged test configuration's environment. The spawner substitutes the
|
||||||
|
// session's port before launching.
|
||||||
|
const runnerPortPlaceholder = "__COMPANION_PORT__"
|
||||||
|
|
||||||
|
// realSpawnRunner extracts the embedded runner bundle, writes a test
|
||||||
|
// configuration bound to the session's port, and starts the hosting test
|
||||||
|
// session against the configured simulator. Cancel sends SIGTERM so the
|
||||||
|
// session tears down its in-simulator children cleanly.
|
||||||
|
func (d *Driver) realSpawnRunner(ctx context.Context, address string) (*exec.Cmd, error) {
|
||||||
|
extractDirectory := filepath.Join(os.TempDir(), "sanderling-runner")
|
||||||
|
testRunPath, err := runnerassets.Extract(extractDirectory)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("extract runner: %w", err)
|
||||||
|
}
|
||||||
|
_, port, err := net.SplitHostPort(address)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
boundTestRunPath, err := bindTestRunPort(testRunPath, port)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
logPath := filepath.Join(extractDirectory, "runner-session-"+port+".log")
|
||||||
|
logFile, err := os.Create(logPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create runner log: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
command := exec.CommandContext(ctx, "xcrun", "xcodebuild", "test-without-building",
|
||||||
|
"-xctestrun", boundTestRunPath,
|
||||||
|
"-destination", "platform=iOS Simulator,id="+d.udid)
|
||||||
|
// The session log stays out of the run output: the build tool is noisy
|
||||||
|
// and would pollute the run's error scan. The path is reported once so a
|
||||||
|
// failed startup is diagnosable.
|
||||||
|
command.Stdout = logFile
|
||||||
|
command.Stderr = logFile
|
||||||
|
command.Env = []string{
|
||||||
|
"HOME=" + os.Getenv("HOME"),
|
||||||
|
"PATH=/usr/bin:/bin",
|
||||||
|
"TMPDIR=" + os.TempDir(),
|
||||||
|
}
|
||||||
|
command.Cancel = func() error { return command.Process.Signal(syscall.SIGTERM) }
|
||||||
|
command.WaitDelay = shutdownGrace
|
||||||
|
startErr := command.Start()
|
||||||
|
// The child holds its own descriptor after Start, so the parent's copy
|
||||||
|
// closes either way.
|
||||||
|
logFile.Close()
|
||||||
|
if startErr != nil {
|
||||||
|
return nil, fmt.Errorf("start runner session: %w", startErr)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(d.output, "runner session pid=%d listening on %s (log: %s)\n", command.Process.Pid, address, logPath)
|
||||||
|
return command, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// bindTestRunPort writes a copy of the extracted test configuration with the
|
||||||
|
// port placeholder substituted, next to the original so its relative paths
|
||||||
|
// keep resolving. Returns the bound copy's path.
|
||||||
|
func bindTestRunPort(testRunPath, port string) (string, error) {
|
||||||
|
configuration, err := os.ReadFile(testRunPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("read test configuration: %w", err)
|
||||||
|
}
|
||||||
|
if !bytes.Contains(configuration, []byte(runnerPortPlaceholder)) {
|
||||||
|
return "", fmt.Errorf("test configuration %s carries no %s placeholder", testRunPath, runnerPortPlaceholder)
|
||||||
|
}
|
||||||
|
bound := bytes.ReplaceAll(configuration, []byte(runnerPortPlaceholder), []byte(port))
|
||||||
|
boundPath := filepath.Join(filepath.Dir(testRunPath), "runner-"+port+".xctestrun")
|
||||||
|
if err := os.WriteFile(boundPath, bound, 0o644); err != nil {
|
||||||
|
return "", fmt.Errorf("write bound test configuration: %w", err)
|
||||||
|
}
|
||||||
|
return boundPath, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
// Package ioscompanion drives an iOS simulator through the native simulator
|
||||||
|
// companion. This file ports the screen-settle (stability polling) logic that
|
||||||
|
// waits for the on-device UI to stop churning before the runner reads a
|
||||||
|
// hierarchy and screenshot pair.
|
||||||
|
package ioscompanion
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/priyanshujain/sanderling/internal/hierarchy"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StabilityPollInterval is how long the loop waits between hierarchy probes.
|
||||||
|
// The companion answers describe-all in tens of milliseconds, so a tight
|
||||||
|
// interval samples transitions promptly without backing the stream up.
|
||||||
|
const StabilityPollInterval = 100 * time.Millisecond
|
||||||
|
|
||||||
|
// MinStableStreak is how long the tree must stay structurally identical (and
|
||||||
|
// non-transitional) before the poll declares settle. Actions whose effect is
|
||||||
|
// async (a tap that fires a write which later pops the back stack) leave the
|
||||||
|
// UI momentarily stable before the navigation transition fires; requiring an
|
||||||
|
// uninterrupted streak means churn that starts during the window resets the
|
||||||
|
// clock instead of being missed. The streak is shorter than the JVM sidecar's
|
||||||
|
// (which polls a slower, flakier adb hierarchy): the companion's describe is
|
||||||
|
// fast and deterministic, so three consecutive identical samples are a solid
|
||||||
|
// stability signal, and cross-fade transitions are caught separately by the
|
||||||
|
// route-screen transitional check rather than by streak length.
|
||||||
|
const MinStableStreak = 300 * time.Millisecond
|
||||||
|
|
||||||
|
// StabilityPollCap bounds total time spent polling so a UI that never settles
|
||||||
|
// does not block the runner indefinitely. A genuinely still-churning screen
|
||||||
|
// hands a transitional snapshot to the next step, which settles it, so the cap
|
||||||
|
// trades a slightly stale read for bounded latency rather than dropping work.
|
||||||
|
const StabilityPollCap = 1500 * time.Millisecond
|
||||||
|
|
||||||
|
// Clock abstracts time so the poll loop can be driven by a fake in tests
|
||||||
|
// without sleeping for real.
|
||||||
|
type Clock interface {
|
||||||
|
Now() time.Time
|
||||||
|
Sleep(duration time.Duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
// systemClock is the production Clock backed by the standard library.
|
||||||
|
type systemClock struct{}
|
||||||
|
|
||||||
|
func (systemClock) Now() time.Time { return time.Now() }
|
||||||
|
func (systemClock) Sleep(d time.Duration) { time.Sleep(d) }
|
||||||
|
|
||||||
|
// SystemClock returns a Clock backed by the standard library wall clock.
|
||||||
|
func SystemClock() Clock { return systemClock{} }
|
||||||
|
|
||||||
|
// PollUntilStable returns once StabilitySnapshot has been non-transitional and
|
||||||
|
// equal to itself for an uninterrupted stretch of at least MinStableStreak,
|
||||||
|
// capped at StabilityPollCap. fetch returns the current hierarchy tree (nil on
|
||||||
|
// fetch failure, treated like a transitional snapshot so the streak resets).
|
||||||
|
//
|
||||||
|
// The stable stretch is measured from the start of the earliest read in the
|
||||||
|
// current run of identical snapshots: a fetch is not instantaneous (a runner
|
||||||
|
// snapshot takes a fair fraction of the streak itself), and the UI changing
|
||||||
|
// mid-read would change the snapshot, so the read's own duration is evidence
|
||||||
|
// of stability. A transitional snapshot, a changed snapshot, or a fetch
|
||||||
|
// failure resets the run. The function returns when the stretch reaches
|
||||||
|
// MinStableStreak or the cap elapses, and respects context cancellation.
|
||||||
|
func PollUntilStable(ctx context.Context, clock Clock, fetch func() *hierarchy.Tree) {
|
||||||
|
deadline := clock.Now().Add(StabilityPollCap)
|
||||||
|
runStart := clock.Now()
|
||||||
|
prior := snapshot(fetch)
|
||||||
|
for clock.Now().Before(deadline) {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clock.Sleep(StabilityPollInterval)
|
||||||
|
currentStart := clock.Now()
|
||||||
|
current := snapshot(fetch)
|
||||||
|
if prior.valid && current.valid && prior.hash == current.hash {
|
||||||
|
if clock.Now().Sub(runStart) >= MinStableStreak {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
runStart = currentStart
|
||||||
|
}
|
||||||
|
prior = current
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stableSnapshot is the result of probing a single hierarchy tree. valid is
|
||||||
|
// false when the snapshot is transitional (mid route transition) or the fetch
|
||||||
|
// failed, both of which must reset the stable streak.
|
||||||
|
type stableSnapshot struct {
|
||||||
|
hash string
|
||||||
|
valid bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshot(fetch func() *hierarchy.Tree) stableSnapshot {
|
||||||
|
tree := fetch()
|
||||||
|
if tree == nil {
|
||||||
|
return stableSnapshot{}
|
||||||
|
}
|
||||||
|
return StabilitySnapshot(tree)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StabilitySnapshot probes a hierarchy tree for both structural shape and route
|
||||||
|
// transition state. An empty tree is a valid stable snapshot (the blank-tree
|
||||||
|
// case in the companion). A tree with more than one route Screen is
|
||||||
|
// transitional and reported invalid: a navigation host keeps both source and
|
||||||
|
// destination destinations alive during a cross-fade, and a snapshot taken in
|
||||||
|
// that window is unreliable because lazy lists in the incoming screen mount
|
||||||
|
// over several frames. Otherwise the snapshot carries the structural hash.
|
||||||
|
func StabilitySnapshot(tree *hierarchy.Tree) stableSnapshot {
|
||||||
|
if tree == nil || tree.Root == nil {
|
||||||
|
return stableSnapshot{valid: true}
|
||||||
|
}
|
||||||
|
if CountRouteScreens(tree) > 1 {
|
||||||
|
return stableSnapshot{}
|
||||||
|
}
|
||||||
|
return stableSnapshot{hash: StructuralHash(tree), valid: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
// routeTagKeys are the attribute keys whose value, when it ends with "Screen",
|
||||||
|
// marks a node as a route-level destination. Mirrors the companion's set.
|
||||||
|
var routeTagKeys = []string{
|
||||||
|
"resource-id", "resourceId", "testTag",
|
||||||
|
"identifier", "accessibilityIdentifier",
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountRouteScreens counts nodes that carry a route-level destination tag (a
|
||||||
|
// route-tag attribute whose value ends with "Screen"). At most one tag is
|
||||||
|
// counted per node, matching the companion which breaks on the first match.
|
||||||
|
func CountRouteScreens(tree *hierarchy.Tree) int {
|
||||||
|
if tree == nil || tree.Root == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return countRouteScreens(tree.Root)
|
||||||
|
}
|
||||||
|
|
||||||
|
func countRouteScreens(node *hierarchy.Node) int {
|
||||||
|
count := 0
|
||||||
|
for _, key := range routeTagKeys {
|
||||||
|
value, ok := node.Attributes[key]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(value, "Screen") {
|
||||||
|
count++
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, child := range node.Children {
|
||||||
|
count += countRouteScreens(child)
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
// stableAttributeKeys are the identity attributes folded into the structural
|
||||||
|
// hash, in this exact order. The transient bounds attribute is deliberately
|
||||||
|
// excluded so a measure pass that shifts pixels without changing what is on
|
||||||
|
// screen does not extend the wait; structure (via tree shape) and text are
|
||||||
|
// included so a real content or layout-tree change does reset stability.
|
||||||
|
var stableAttributeKeys = []string{
|
||||||
|
"resource-id", "resourceId",
|
||||||
|
"class", "className",
|
||||||
|
"content-desc", "contentDescription", "accessibilityText",
|
||||||
|
"text",
|
||||||
|
"testTag", "identifier", "accessibilityIdentifier",
|
||||||
|
}
|
||||||
|
|
||||||
|
// StructuralHash walks the tree and concatenates only the stable identity
|
||||||
|
// attributes (excluding bounds), in tree order, wrapping each node in
|
||||||
|
// parentheses so tree shape is encoded. The result is compared by equality, so
|
||||||
|
// it is the hash itself rather than a digest of it.
|
||||||
|
func StructuralHash(tree *hierarchy.Tree) string {
|
||||||
|
if tree == nil || tree.Root == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var builder strings.Builder
|
||||||
|
walkForStructuralHash(tree.Root, &builder)
|
||||||
|
return builder.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func walkForStructuralHash(node *hierarchy.Node, out *strings.Builder) {
|
||||||
|
out.WriteByte('(')
|
||||||
|
for _, key := range stableAttributeKeys {
|
||||||
|
value, ok := node.Attributes[key]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out.WriteString(key)
|
||||||
|
out.WriteByte(':')
|
||||||
|
out.WriteString(value)
|
||||||
|
out.WriteByte('|')
|
||||||
|
}
|
||||||
|
for _, child := range node.Children {
|
||||||
|
walkForStructuralHash(child, out)
|
||||||
|
}
|
||||||
|
out.WriteByte(')')
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
package ioscompanion
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/priyanshujain/sanderling/internal/hierarchy"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeClock advances its internal time only when Sleep is called, so the poll
|
||||||
|
// loop runs instantly and deterministically without real sleeping.
|
||||||
|
type fakeClock struct {
|
||||||
|
now time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *fakeClock) Now() time.Time { return c.now }
|
||||||
|
|
||||||
|
func (c *fakeClock) Sleep(d time.Duration) { c.now = c.now.Add(d) }
|
||||||
|
|
||||||
|
func mustTree(t *testing.T, json string) *hierarchy.Tree {
|
||||||
|
t.Helper()
|
||||||
|
tree, err := hierarchy.Parse(json)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse hierarchy: %v", err)
|
||||||
|
}
|
||||||
|
return tree
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetcher returns a fetch function yielding trees in sequence; once the
|
||||||
|
// sequence is exhausted the last tree repeats forever.
|
||||||
|
func fetcher(trees ...*hierarchy.Tree) func() *hierarchy.Tree {
|
||||||
|
index := 0
|
||||||
|
return func() *hierarchy.Tree {
|
||||||
|
tree := trees[index]
|
||||||
|
if index < len(trees)-1 {
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
return tree
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const singleScreenJSON = `{
|
||||||
|
"attributes": {"resource-id": "homeScreen", "text": "Home"},
|
||||||
|
"children": [
|
||||||
|
{"attributes": {"text": "Welcome", "bounds": "[0,0,100,50]"}, "children": []}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
|
||||||
|
// twoScreenJSON carries two route Screens (a navigation cross-fade), which must
|
||||||
|
// be treated as transitional.
|
||||||
|
const twoScreenJSON = `{
|
||||||
|
"attributes": {"resource-id": "rootView"},
|
||||||
|
"children": [
|
||||||
|
{"attributes": {"testTag": "homeScreen"}, "children": []},
|
||||||
|
{"attributes": {"testTag": "detailScreen"}, "children": []}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
|
||||||
|
func TestCountRouteScreens(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
json string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"single screen", singleScreenJSON, 1},
|
||||||
|
{"two screens", twoScreenJSON, 2},
|
||||||
|
{"no screens", `{"attributes": {"text": "plain"}, "children": []}`, 0},
|
||||||
|
{
|
||||||
|
"one node only counts once across route keys",
|
||||||
|
`{"attributes": {"resource-id": "fooScreen", "testTag": "barScreen"}, "children": []}`,
|
||||||
|
1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
got := CountRouteScreens(mustTree(t, test.json))
|
||||||
|
if got != test.want {
|
||||||
|
t.Fatalf("CountRouteScreens = %d, want %d", got, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStabilitySnapshotTreatsTwoScreensAsTransitional(t *testing.T) {
|
||||||
|
snap := StabilitySnapshot(mustTree(t, twoScreenJSON))
|
||||||
|
if snap.valid {
|
||||||
|
t.Fatalf("snapshot with two route Screens should be invalid (transitional)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStructuralHashIgnoresBoundsButNotTextOrStructure(t *testing.T) {
|
||||||
|
base := `{"attributes": {"text": "Hi", "bounds": "[0,0,10,10]"}, "children": [
|
||||||
|
{"attributes": {"text": "child", "bounds": "[0,0,5,5]"}, "children": []}
|
||||||
|
]}`
|
||||||
|
boundsShifted := `{"attributes": {"text": "Hi", "bounds": "[5,5,20,20]"}, "children": [
|
||||||
|
{"attributes": {"text": "child", "bounds": "[5,5,9,9]"}, "children": []}
|
||||||
|
]}`
|
||||||
|
textChanged := `{"attributes": {"text": "Bye", "bounds": "[0,0,10,10]"}, "children": [
|
||||||
|
{"attributes": {"text": "child", "bounds": "[0,0,5,5]"}, "children": []}
|
||||||
|
]}`
|
||||||
|
structureChanged := `{"attributes": {"text": "Hi", "bounds": "[0,0,10,10]"}, "children": [
|
||||||
|
{"attributes": {"text": "child", "bounds": "[0,0,5,5]"}, "children": []},
|
||||||
|
{"attributes": {"text": "extra"}, "children": []}
|
||||||
|
]}`
|
||||||
|
|
||||||
|
baseHash := StructuralHash(mustTree(t, base))
|
||||||
|
if got := StructuralHash(mustTree(t, boundsShifted)); got != baseHash {
|
||||||
|
t.Fatalf("bounds-only change must not alter hash:\n base=%q\n shift=%q", baseHash, got)
|
||||||
|
}
|
||||||
|
if got := StructuralHash(mustTree(t, textChanged)); got == baseHash {
|
||||||
|
t.Fatalf("text change must alter hash, both were %q", baseHash)
|
||||||
|
}
|
||||||
|
if got := StructuralHash(mustTree(t, structureChanged)); got == baseHash {
|
||||||
|
t.Fatalf("structure change must alter hash, both were %q", baseHash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPollUntilStableTwoScreensNeverStable(t *testing.T) {
|
||||||
|
clock := &fakeClock{now: time.Unix(0, 0)}
|
||||||
|
fetch := fetcher(mustTree(t, twoScreenJSON))
|
||||||
|
start := clock.Now()
|
||||||
|
PollUntilStable(context.Background(), clock, fetch)
|
||||||
|
elapsed := clock.Now().Sub(start)
|
||||||
|
if elapsed < StabilityPollCap {
|
||||||
|
t.Fatalf("a perpetually transitional UI must poll until the cap, elapsed=%v cap=%v", elapsed, StabilityPollCap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPollUntilStableReturnsAfterStreak(t *testing.T) {
|
||||||
|
clock := &fakeClock{now: time.Unix(0, 0)}
|
||||||
|
fetch := fetcher(mustTree(t, singleScreenJSON))
|
||||||
|
start := clock.Now()
|
||||||
|
PollUntilStable(context.Background(), clock, fetch)
|
||||||
|
elapsed := clock.Now().Sub(start)
|
||||||
|
// prior is sampled at t0, then equal snapshots accumulate the streak. The
|
||||||
|
// streak starts at the first matching tick and must reach MinStableStreak.
|
||||||
|
if elapsed > StabilityPollCap {
|
||||||
|
t.Fatalf("stable UI must settle before the cap, elapsed=%v", elapsed)
|
||||||
|
}
|
||||||
|
if elapsed < MinStableStreak {
|
||||||
|
t.Fatalf("must observe a full stable streak before returning, elapsed=%v streak=%v", elapsed, MinStableStreak)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPollUntilStableResetsStreakOnMidStreakChange(t *testing.T) {
|
||||||
|
clock := &fakeClock{now: time.Unix(0, 0)}
|
||||||
|
stable := mustTree(t, singleScreenJSON)
|
||||||
|
churned := mustTree(t, `{"attributes": {"resource-id": "homeScreen", "text": "Loading"}, "children": []}`)
|
||||||
|
// Two stable ticks build part of a streak (250ms, 500ms accumulated since
|
||||||
|
// the streak start at the first match) then a changed tree resets it, after
|
||||||
|
// which a fresh full 750ms streak is required.
|
||||||
|
fetch := fetcher(stable, stable, stable, churned, stable)
|
||||||
|
start := clock.Now()
|
||||||
|
PollUntilStable(context.Background(), clock, fetch)
|
||||||
|
elapsed := clock.Now().Sub(start)
|
||||||
|
// The streak that succeeds begins after the churn, so total elapsed must
|
||||||
|
// exceed a single uninterrupted streak. Without a reset the loop would have
|
||||||
|
// returned far earlier.
|
||||||
|
if elapsed < MinStableStreak+3*StabilityPollInterval {
|
||||||
|
t.Fatalf("mid-streak change must reset and require a fresh streak, elapsed=%v", elapsed)
|
||||||
|
}
|
||||||
|
if elapsed > StabilityPollCap {
|
||||||
|
t.Fatalf("expected settle before cap once a clean streak completes, elapsed=%v", elapsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPollUntilStableCapReturnsWhenNeverStable(t *testing.T) {
|
||||||
|
clock := &fakeClock{now: time.Unix(0, 0)}
|
||||||
|
// Each fetch yields a structurally different tree, so the streak never
|
||||||
|
// grows and only the cap can end the loop.
|
||||||
|
tick := 0
|
||||||
|
fetch := func() *hierarchy.Tree {
|
||||||
|
tick++
|
||||||
|
return mustTree(t, fmt.Sprintf(`{"attributes": {"text": "frame-%d"}, "children": []}`, tick))
|
||||||
|
}
|
||||||
|
start := clock.Now()
|
||||||
|
PollUntilStable(context.Background(), clock, fetch)
|
||||||
|
elapsed := clock.Now().Sub(start)
|
||||||
|
if elapsed < StabilityPollCap {
|
||||||
|
t.Fatalf("never-stable UI must poll up to the cap, elapsed=%v cap=%v", elapsed, StabilityPollCap)
|
||||||
|
}
|
||||||
|
if elapsed > StabilityPollCap+StabilityPollInterval {
|
||||||
|
t.Fatalf("must not overshoot the cap by more than one interval, elapsed=%v", elapsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPollUntilStableHonorsContextCancellation(t *testing.T) {
|
||||||
|
clock := &fakeClock{now: time.Unix(0, 0)}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
fetch := fetcher(mustTree(t, twoScreenJSON))
|
||||||
|
start := clock.Now()
|
||||||
|
PollUntilStable(ctx, clock, fetch)
|
||||||
|
if elapsed := clock.Now().Sub(start); elapsed > StabilityPollInterval {
|
||||||
|
t.Fatalf("cancelled context must stop the loop promptly, elapsed=%v", elapsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
//go:build withcompanion
|
||||||
|
|
||||||
|
package ioscompanion
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSmokeNewHierarchyScreenshot brings up the embedded companion against the
|
||||||
|
// booted simulator, reads one hierarchy and screenshot, closes, and asserts the
|
||||||
|
// companion child left no orphan behind. It is gated behind both the
|
||||||
|
// withcompanion build tag (so the binary is embedded) and an environment
|
||||||
|
// variable, so it never runs in the default suite.
|
||||||
|
func TestSmokeNewHierarchyScreenshot(t *testing.T) {
|
||||||
|
if os.Getenv("SANDERLING_IOS_INTEGRATION") == "" {
|
||||||
|
t.Skip("set SANDERLING_IOS_INTEGRATION=1 to run the companion smoke test")
|
||||||
|
}
|
||||||
|
udid := bootedUDID(t)
|
||||||
|
|
||||||
|
before := companionProcessCount(t, udid)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
d, err := New(ctx, Options{UniqueDeviceIdentifier: udid, Output: os.Stderr})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := d.Hierarchy(ctx); err != nil {
|
||||||
|
d.Close()
|
||||||
|
t.Fatalf("Hierarchy: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := d.Screenshot(ctx); err != nil {
|
||||||
|
d.Close()
|
||||||
|
t.Fatalf("Screenshot: %v", err)
|
||||||
|
}
|
||||||
|
d.Close()
|
||||||
|
|
||||||
|
// Give the child a moment to exit after SIGTERM, then confirm no orphan.
|
||||||
|
deadline := time.Now().Add(10 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if companionProcessCount(t, udid) <= before {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatalf("companion process for %s outlived Close (orphan)", udid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func bootedUDID(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
output, err := exec.Command("xcrun", "simctl", "list", "devices", "booted").Output()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("simctl list: %v", err)
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(string(output), "\n") {
|
||||||
|
if start := strings.Index(line, "("); start >= 0 {
|
||||||
|
if end := strings.Index(line[start:], ")"); end > 0 {
|
||||||
|
candidate := line[start+1 : start+end]
|
||||||
|
if len(candidate) == 36 {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Skip("no booted simulator available")
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func companionProcessCount(t *testing.T, udid string) int {
|
||||||
|
t.Helper()
|
||||||
|
output, _ := exec.Command("pgrep", "-f", udid).Output()
|
||||||
|
count := 0
|
||||||
|
for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") {
|
||||||
|
if strings.TrimSpace(line) != "" {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
package ioscompanion
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"os/exec"
|
||||||
|
"syscall"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/priyanshujain/sanderling/internal/driver/ioscompanion/transport"
|
||||||
|
)
|
||||||
|
|
||||||
|
// These tests cover the process-supervision logic (stopProcess, bringUp,
|
||||||
|
// respawnAndRedial) with real child processes and seamed transports, so the
|
||||||
|
// default suite exercises it without a simulator.
|
||||||
|
|
||||||
|
func TestStopProcessReapsChildOnSigterm(t *testing.T) {
|
||||||
|
child := exec.Command("sleep", "30")
|
||||||
|
if err := child.Start(); err != nil {
|
||||||
|
t.Fatalf("start child: %v", err)
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
stopProcess(child)
|
||||||
|
if elapsed := time.Since(start); elapsed > 5*time.Second {
|
||||||
|
t.Fatalf("stopProcess took %v; SIGTERM on sleep should reap promptly", elapsed)
|
||||||
|
}
|
||||||
|
if child.ProcessState == nil {
|
||||||
|
t.Fatal("child not reaped: ProcessState is nil")
|
||||||
|
}
|
||||||
|
if err := child.Process.Signal(syscall.Signal(0)); err == nil {
|
||||||
|
t.Fatal("child still signalable after stopProcess")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStopProcessEscalatesToKillWhenSigtermIgnored(t *testing.T) {
|
||||||
|
previousGrace := shutdownGrace
|
||||||
|
shutdownGrace = 200 * time.Millisecond
|
||||||
|
defer func() { shutdownGrace = previousGrace }()
|
||||||
|
|
||||||
|
child := exec.Command("sh", "-c", `trap "" TERM; sleep 30`)
|
||||||
|
if err := child.Start(); err != nil {
|
||||||
|
t.Fatalf("start child: %v", err)
|
||||||
|
}
|
||||||
|
// Give the shell a beat to install its TERM trap.
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
stopProcess(child)
|
||||||
|
if child.ProcessState == nil {
|
||||||
|
t.Fatal("child not reaped: ProcessState is nil")
|
||||||
|
}
|
||||||
|
if child.ProcessState.Success() {
|
||||||
|
t.Fatal("child should have died by signal, not exited cleanly")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStopProcessHandlesNilChild(t *testing.T) {
|
||||||
|
stopProcess(nil)
|
||||||
|
stopProcess(&exec.Cmd{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRespawnAndRedialReplacesTransportAndChild drives the real restart path
|
||||||
|
// through the seams: the old transport must be closed, a fresh child spawned,
|
||||||
|
// and the fresh transport installed.
|
||||||
|
func TestRespawnAndRedialReplacesTransportAndChild(t *testing.T) {
|
||||||
|
t.Setenv("SANDERLING_SIMULATOR_COMPANION", "legacy")
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer listener.Close()
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
connection, acceptErr := listener.Accept()
|
||||||
|
if acceptErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = connection.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
spawns := 0
|
||||||
|
dials := []*fakeCompanion{}
|
||||||
|
options := Options{
|
||||||
|
UniqueDeviceIdentifier: "FAKE-UDID",
|
||||||
|
pickAddress: func() (string, error) { return listener.Addr().String(), nil },
|
||||||
|
spawnChild: func(context.Context, string) (*exec.Cmd, error) {
|
||||||
|
spawns++
|
||||||
|
return &exec.Cmd{}, nil
|
||||||
|
},
|
||||||
|
dialCompanion: func(string) (transport.Companion, error) {
|
||||||
|
companion := &fakeCompanion{accessibilityJSON: "[]"}
|
||||||
|
dials = append(dials, companion)
|
||||||
|
return companion, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
d, err := New(context.Background(), options)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer d.Close()
|
||||||
|
|
||||||
|
first := dials[0]
|
||||||
|
if err := d.respawnAndRedial(context.Background()); err != nil {
|
||||||
|
t.Fatalf("respawnAndRedial: %v", err)
|
||||||
|
}
|
||||||
|
if spawns != 2 {
|
||||||
|
t.Fatalf("spawns = %d, want 2 (initial bring-up plus restart)", spawns)
|
||||||
|
}
|
||||||
|
if len(dials) != 2 || d.companion != transport.Companion(dials[1]) {
|
||||||
|
t.Fatalf("restart must install the freshly dialed transport")
|
||||||
|
}
|
||||||
|
if indexOf(first.recorded(), "close") < 0 {
|
||||||
|
t.Fatal("restart must close the dead transport")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBringUpStopsChildWhenDialFails proves a failed bring-up does not leak
|
||||||
|
// the child it spawned.
|
||||||
|
func TestBringUpStopsChildWhenDialFails(t *testing.T) {
|
||||||
|
t.Setenv("SANDERLING_SIMULATOR_COMPANION", "legacy")
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer listener.Close()
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
connection, acceptErr := listener.Accept()
|
||||||
|
if acceptErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = connection.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
child := exec.Command("sleep", "30")
|
||||||
|
if err := child.Start(); err != nil {
|
||||||
|
t.Fatalf("start child: %v", err)
|
||||||
|
}
|
||||||
|
options := Options{
|
||||||
|
UniqueDeviceIdentifier: "FAKE-UDID",
|
||||||
|
pickAddress: func() (string, error) { return listener.Addr().String(), nil },
|
||||||
|
spawnChild: func(context.Context, string) (*exec.Cmd, error) {
|
||||||
|
return child, nil
|
||||||
|
},
|
||||||
|
dialCompanion: func(string) (transport.Companion, error) {
|
||||||
|
return nil, errors.New("dial refused")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if _, err := New(context.Background(), options); err == nil {
|
||||||
|
t.Fatal("New should fail when dial fails")
|
||||||
|
}
|
||||||
|
if child.ProcessState == nil {
|
||||||
|
t.Fatal("failed bring-up must reap the spawned child")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[{"AXFrame":"{{0, 0}, {402, 874}}","AXUniqueId":null,"frame":{"y":0,"x":0,"width":402,"height":874},"role_description":"application","AXLabel":"Folio","content_required":false,"type":"Application","title":null,"help":null,"custom_actions":[],"AXValue":null,"enabled":true,"role":"AXApplication","subrole":null},{"AXFrame":"{{20, 76}, {97.333335876464844, 24}}","AXUniqueId":null,"frame":{"y":76,"x":20,"width":97.333335876464844,"height":24},"role_description":"text","AXLabel":"Accounts","content_required":false,"type":"StaticText","title":null,"help":null,"custom_actions":[],"AXValue":null,"enabled":true,"role":"AXStaticText","subrole":null},{"AXFrame":"{{340, 71.333335876464844}, {48, 48}}","AXUniqueId":"LogoutButton","frame":{"y":71.333335876464844,"x":340,"width":48,"height":48},"role_description":"button","AXLabel":"Log out","content_required":false,"type":"Button","title":null,"help":null,"custom_actions":[],"AXValue":null,"enabled":true,"role":"AXButton","subrole":null},{"AXFrame":"{{20, 100}, {104, 14.333335876464844}}","AXUniqueId":null,"frame":{"y":100,"x":20,"width":104,"height":14.333335876464844},"role_description":"text","AXLabel":"[email protected]","content_required":false,"type":"StaticText","title":null,"help":null,"custom_actions":[],"AXValue":null,"enabled":true,"role":"AXStaticText","subrole":null},{"AXFrame":"{{131.33332824707031, 248.33332824707031}, {139.33332824707031, 17.999984741210938}}","AXUniqueId":null,"frame":{"y":248.33332824707031,"x":131.33332824707031,"width":139.33332824707031,"height":17.999984741210938},"role_description":"text","AXLabel":"No accounts yet","content_required":false,"type":"StaticText","title":null,"help":null,"custom_actions":[],"AXValue":null,"enabled":true,"role":"AXStaticText","subrole":null},{"AXFrame":"{{36, 272.33334350585938}, {330, 28.666656494140625}}","AXUniqueId":null,"frame":{"y":272.33334350585938,"x":36,"width":330,"height":28.666656494140625},"role_description":"text","AXLabel":"Create your first account to start tracking transactions.","content_required":false,"type":"StaticText","title":null,"help":null,"custom_actions":[],"AXValue":null,"enabled":true,"role":"AXStaticText","subrole":null},{"AXFrame":"{{20, 716.33331298828125}, {105.66666412353516, 14.33331298828125}}","AXUniqueId":null,"frame":{"y":716.33331298828125,"x":20,"width":105.66666412353516,"height":14.33331298828125},"role_description":"text","AXLabel":"TOTAL BALANCE","content_required":false,"type":"StaticText","title":null,"help":null,"custom_actions":[],"AXValue":null,"enabled":true,"role":"AXStaticText","subrole":null},{"AXFrame":"{{20, 730.66668701171875}, {85.333335876464844, 33.33331298828125}}","AXUniqueId":null,"frame":{"y":730.66668701171875,"x":20,"width":85.333335876464844,"height":33.33331298828125},"role_description":"text","AXLabel":"$0.00","content_required":false,"type":"StaticText","title":null,"help":null,"custom_actions":[],"AXValue":null,"enabled":true,"role":"AXStaticText","subrole":null},{"AXFrame":"{{307.66665649414062, 749.66668701171875}, {74.333343505859375, 14.33331298828125}}","AXUniqueId":null,"frame":{"y":749.66668701171875,"x":307.66665649414062,"width":74.333343505859375,"height":14.33331298828125},"role_description":"text","AXLabel":"0 accounts","content_required":false,"type":"StaticText","title":null,"help":null,"custom_actions":[],"AXValue":null,"enabled":true,"role":"AXStaticText","subrole":null},{"AXFrame":"{{20, 777}, {362, 48}}","AXUniqueId":"AddAccountButton","frame":{"y":777,"x":20,"width":362,"height":48},"role_description":"button","AXLabel":"+ Add account","content_required":false,"type":"Button","title":null,"help":null,"custom_actions":[],"AXValue":null,"enabled":true,"role":"AXButton","subrole":null}]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[{"AXFrame":"{{0, 0}, {0, 0}}","AXUniqueId":null,"frame":{"y":0,"x":0,"width":0,"height":0},"role_description":"application","AXLabel":null,"content_required":false,"type":"Application","title":null,"help":null,"custom_actions":[],"AXValue":null,"enabled":true,"role":"AXApplication","subrole":null},{"AXFrame":"{{20, 106}, {40.666667938232422, 14.333335876464844}}","AXUniqueId":null,"frame":{"y":106,"x":20,"width":40.666667938232422,"height":14.333335876464844},"role_description":"text","AXLabel":"EMAIL","content_required":false,"type":"StaticText","title":null,"help":null,"custom_actions":[],"AXValue":null,"enabled":true,"role":"AXStaticText","subrole":null},{"AXFrame":"{{34, 125.33333587646484}, {334, 48.000007629394531}}","AXUniqueId":"LoginEmail","frame":{"y":125.33333587646484,"x":34,"width":334,"height":48.000007629394531},"role_description":"text entry area","AXLabel":"Email","content_required":false,"type":"TextArea","title":null,"help":null,"custom_actions":[],"AXValue":null,"enabled":true,"role":"AXTextArea","subrole":null},{"AXFrame":"{{20, 186.33332824707031}, {65, 14.333328247070312}}","AXUniqueId":null,"frame":{"y":186.33332824707031,"x":20,"width":65,"height":14.333328247070312},"role_description":"text","AXLabel":"PASSWORD","content_required":false,"type":"StaticText","title":null,"help":null,"custom_actions":[],"AXValue":null,"enabled":true,"role":"AXStaticText","subrole":null},{"AXFrame":"{{34, 205.66667175292969}, {334, 48}}","AXUniqueId":"LoginPassword","frame":{"y":205.66667175292969,"x":34,"width":334,"height":48},"role_description":"text entry area","AXLabel":"Password","content_required":false,"type":"TextArea","title":null,"help":null,"custom_actions":[],"AXValue":null,"enabled":true,"role":"AXTextArea","subrole":null},{"AXFrame":"{{20, 266.66665649414062}, {362, 18}}","AXUniqueId":null,"frame":{"y":266.66665649414062,"x":20,"width":362,"height":18},"role_description":"text","AXLabel":"","content_required":false,"type":"StaticText","title":null,"help":null,"custom_actions":[],"AXValue":null,"enabled":true,"role":"AXStaticText","subrole":null},{"AXFrame":"{{20, 297.66665649414062}, {362, 48}}","AXUniqueId":"LoginSubmit","frame":{"y":297.66665649414062,"x":20,"width":362,"height":48},"role_description":"button","AXLabel":"Sign in","content_required":false,"type":"Button","title":null,"help":null,"custom_actions":[],"AXValue":null,"enabled":true,"role":"AXButton","subrole":null},{"AXFrame":"{{36, 392.66665649414062}, {119, 14.333343505859375}}","AXUniqueId":null,"frame":{"y":392.66665649414062,"x":36,"width":119,"height":14.333343505859375},"role_description":"text","AXLabel":"Demo credentials","content_required":false,"type":"StaticText","title":null,"help":null,"custom_actions":[],"AXValue":null,"enabled":true,"role":"AXStaticText","subrole":null},{"AXFrame":"{{36, 413}, {195, 18}}","AXUniqueId":null,"frame":{"y":413,"x":36,"width":195,"height":18},"role_description":"text","AXLabel":"email: [email protected]","content_required":false,"type":"StaticText","title":null,"help":null,"custom_actions":[],"AXValue":null,"enabled":true,"role":"AXStaticText","subrole":null},{"AXFrame":"{{36, 437}, {176.33332824707031, 18}}","AXUniqueId":null,"frame":{"y":437,"x":36,"width":176.33332824707031,"height":18},"role_description":"text","AXLabel":"password: ledger123","content_required":false,"type":"StaticText","title":null,"help":null,"custom_actions":[],"AXValue":null,"enabled":true,"role":"AXStaticText","subrole":null}]
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
[
|
||||||
|
{"type": "Window", "frame": {"x": 0, "y": 0, "width": 390, "height": 844}, "enabled": true},
|
||||||
|
{"type": "TextField", "AXUniqueId": "TxnNoteField", "AXValue": "", "AXLabel": "Note", "frame": {"x": 20, "y": 200, "width": 350, "height": 44}, "enabled": true},
|
||||||
|
{"type": "StaticText", "AXLabel": "Allow Pasting", "frame": {"x": 95, "y": 380, "width": 200, "height": 22}, "enabled": true},
|
||||||
|
{"type": "Button", "AXLabel": "Don’t Allow Paste", "frame": {"x": 40, "y": 440, "width": 140, "height": 50}, "enabled": true},
|
||||||
|
{"type": "Button", "AXLabel": "Allow Paste", "frame": {"x": 210, "y": 440, "width": 140, "height": 50}, "enabled": true}
|
||||||
|
]
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
package transport
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
|
|
||||||
|
pb "github.com/priyanshujain/sanderling/internal/driver/ioscompanion/companionpb"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxReceiveBytes is generous because screenshots can be large.
|
||||||
|
const maxReceiveBytes = 64 * 1024 * 1024
|
||||||
|
|
||||||
|
type grpcCompanion struct {
|
||||||
|
conn *grpc.ClientConn
|
||||||
|
client pb.CompanionServiceClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dial connects to the companion listening at address and returns a Companion.
|
||||||
|
func Dial(address string) (Companion, error) {
|
||||||
|
conn, err := grpc.NewClient(address,
|
||||||
|
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||||
|
grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(maxReceiveBytes)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &grpcCompanion{conn: conn, client: pb.NewCompanionServiceClient(conn)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *grpcCompanion) Close() error { return c.conn.Close() }
|
||||||
|
|
||||||
|
func (c *grpcCompanion) AccessibilityInfo(ctx context.Context) (string, error) {
|
||||||
|
resp, err := c.client.AccessibilityInfo(ctx, &pb.AccessibilityInfoRequest{
|
||||||
|
Format: pb.AccessibilityInfoRequest_LEGACY,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return resp.GetJson(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *grpcCompanion) Describe(ctx context.Context) (ScreenDescription, error) {
|
||||||
|
resp, err := c.client.Describe(ctx, &pb.TargetDescriptionRequest{})
|
||||||
|
if err != nil {
|
||||||
|
return ScreenDescription{}, err
|
||||||
|
}
|
||||||
|
return screenDescriptionFrom(resp), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// screenDescriptionFrom extracts the point dimensions and scale from a describe
|
||||||
|
// response. The generated getters are nil-safe, so a response missing the
|
||||||
|
// nested messages yields a zero-valued ScreenDescription rather than panicking.
|
||||||
|
func screenDescriptionFrom(resp *pb.TargetDescriptionResponse) ScreenDescription {
|
||||||
|
dimensions := resp.GetTargetDescription().GetScreenDimensions()
|
||||||
|
return ScreenDescription{
|
||||||
|
WidthPoints: int(dimensions.GetWidthPoints()),
|
||||||
|
HeightPoints: int(dimensions.GetHeightPoints()),
|
||||||
|
Scale: dimensions.GetDensity(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *grpcCompanion) SendHID(ctx context.Context, events ...HIDEvent) error {
|
||||||
|
stream, err := c.client.Hid(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, e := range events {
|
||||||
|
message, err := hidEventToProto(e)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := stream.Send(message); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err = stream.CloseAndRecv()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// hidEventToProto encodes a neutral HID event as this companion's wire event.
|
||||||
|
// The companion measures delays and swipe durations in seconds.
|
||||||
|
func hidEventToProto(e HIDEvent) (*pb.HIDEvent, error) {
|
||||||
|
switch e.Kind {
|
||||||
|
case HIDKindTouchDown:
|
||||||
|
return touchProto(e.X, e.Y, pb.HIDEvent_DOWN), nil
|
||||||
|
case HIDKindTouchUp:
|
||||||
|
return touchProto(e.X, e.Y, pb.HIDEvent_UP), nil
|
||||||
|
case HIDKindKeyDown:
|
||||||
|
return keyProto(e.Usage, pb.HIDEvent_DOWN), nil
|
||||||
|
case HIDKindKeyUp:
|
||||||
|
return keyProto(e.Usage, pb.HIDEvent_UP), nil
|
||||||
|
case HIDKindDelay:
|
||||||
|
return &pb.HIDEvent{Event: &pb.HIDEvent_Delay{
|
||||||
|
Delay: &pb.HIDEvent_HIDDelay{Duration: e.Milliseconds / 1000.0},
|
||||||
|
}}, nil
|
||||||
|
case HIDKindSwipe:
|
||||||
|
return &pb.HIDEvent{Event: &pb.HIDEvent_Swipe{Swipe: &pb.HIDEvent_HIDSwipe{
|
||||||
|
Start: &pb.Point{X: e.FromX, Y: e.FromY},
|
||||||
|
End: &pb.Point{X: e.ToX, Y: e.ToY},
|
||||||
|
Duration: e.Seconds,
|
||||||
|
}}}, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("unknown HID event kind %d", e.Kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
func touchProto(x, y float64, direction pb.HIDEvent_HIDDirection) *pb.HIDEvent {
|
||||||
|
return &pb.HIDEvent{Event: &pb.HIDEvent_Press{Press: &pb.HIDEvent_HIDPress{
|
||||||
|
Direction: direction,
|
||||||
|
Action: &pb.HIDEvent_HIDPressAction{Action: &pb.HIDEvent_HIDPressAction_Touch{
|
||||||
|
Touch: &pb.HIDEvent_HIDTouch{Point: &pb.Point{X: x, Y: y}},
|
||||||
|
}},
|
||||||
|
}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func keyProto(usage uint32, direction pb.HIDEvent_HIDDirection) *pb.HIDEvent {
|
||||||
|
return &pb.HIDEvent{Event: &pb.HIDEvent_Press{Press: &pb.HIDEvent_HIDPress{
|
||||||
|
Direction: direction,
|
||||||
|
Action: &pb.HIDEvent_HIDPressAction{Action: &pb.HIDEvent_HIDPressAction_Key{
|
||||||
|
Key: &pb.HIDEvent_HIDKey{Keycode: uint64(usage)},
|
||||||
|
}},
|
||||||
|
}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *grpcCompanion) Screenshot(ctx context.Context) ([]byte, string, error) {
|
||||||
|
resp, err := c.client.Screenshot(ctx, &pb.ScreenshotRequest{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return resp.GetImageData(), resp.GetImageFormat(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *grpcCompanion) Launch(ctx context.Context, bundleID string, foregroundIfRunning bool) error {
|
||||||
|
stream, err := c.client.Launch(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = stream.Send(&pb.LaunchRequest{Control: &pb.LaunchRequest_Start_{Start: &pb.LaunchRequest_Start{
|
||||||
|
BundleId: bundleID,
|
||||||
|
ForegroundIfRunning: foregroundIfRunning,
|
||||||
|
}}})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := stream.Recv(); err != nil && err != io.EOF {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return stream.CloseSend()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *grpcCompanion) Terminate(ctx context.Context, bundleID string) error {
|
||||||
|
_, err := c.client.Terminate(ctx, &pb.TerminateRequest{BundleId: bundleID})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *grpcCompanion) Uninstall(ctx context.Context, bundleID string) error {
|
||||||
|
_, err := c.client.Uninstall(ctx, &pb.UninstallRequest{BundleId: bundleID})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *grpcCompanion) ListApps(ctx context.Context) ([]InstalledApp, error) {
|
||||||
|
resp, err := c.client.ListApps(ctx, &pb.ListAppsRequest{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
apps := make([]InstalledApp, 0, len(resp.GetApps()))
|
||||||
|
for _, a := range resp.GetApps() {
|
||||||
|
apps = append(apps, InstalledApp{
|
||||||
|
BundleID: a.GetBundleId(),
|
||||||
|
Name: a.GetName(),
|
||||||
|
InstallType: a.GetInstallType(),
|
||||||
|
ProcessState: processStateFromProto(a.GetProcessState()),
|
||||||
|
Debuggable: a.GetDebuggable(),
|
||||||
|
ProcessIdentifier: a.GetProcessIdentifier(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return apps, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// installChunkBytes keeps each install payload frame comfortably under the
|
||||||
|
// companion's 16MiB incoming-message cap.
|
||||||
|
const installChunkBytes = 4 * 1024 * 1024
|
||||||
|
|
||||||
|
func (c *grpcCompanion) Install(ctx context.Context, appPath string) error {
|
||||||
|
info, err := os.Stat(appPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
return fmt.Errorf("install: %s is not an app bundle directory", appPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
stream, err := c.client.Install(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// First message sets the destination, then the payload carries the bundle
|
||||||
|
// as a gzip-compressed tar archive the companion unpacks.
|
||||||
|
if err := stream.Send(&pb.InstallRequest{Value: &pb.InstallRequest_Destination_{
|
||||||
|
Destination: pb.InstallRequest_APP,
|
||||||
|
}}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
archive, err := tarGzipDirectory(appPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// The companion caps incoming messages at 16MiB, so the archive streams in
|
||||||
|
// chunks; the companion concatenates consecutive data payloads.
|
||||||
|
for _, chunk := range payloadChunks(archive, installChunkBytes) {
|
||||||
|
if err := stream.Send(&pb.InstallRequest{Value: &pb.InstallRequest_Payload{
|
||||||
|
Payload: &pb.Payload{Source: &pb.Payload_Data{Data: chunk}},
|
||||||
|
}}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := stream.CloseSend(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
if _, err := stream.Recv(); err != nil {
|
||||||
|
if err == io.EOF {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// payloadChunks splits data into consecutive slices of at most chunkBytes.
|
||||||
|
func payloadChunks(data []byte, chunkBytes int) [][]byte {
|
||||||
|
var chunks [][]byte
|
||||||
|
for offset := 0; offset < len(data); offset += chunkBytes {
|
||||||
|
end := min(offset+chunkBytes, len(data))
|
||||||
|
chunks = append(chunks, data[offset:end])
|
||||||
|
}
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
// tarGzipDirectory packs dir into a gzip-compressed tar archive. Entry paths are
|
||||||
|
// relative to the parent of dir so the bundle directory itself is preserved.
|
||||||
|
func tarGzipDirectory(dir string) ([]byte, error) {
|
||||||
|
var buffer bytes.Buffer
|
||||||
|
gzipWriter := gzip.NewWriter(&buffer)
|
||||||
|
tarWriter := tar.NewWriter(gzipWriter)
|
||||||
|
|
||||||
|
base := filepath.Dir(dir)
|
||||||
|
walkErr := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
relative, err := filepath.Rel(base, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
header, err := tar.FileInfoHeader(info, "")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
header.Name = filepath.ToSlash(relative)
|
||||||
|
if err := tarWriter.WriteHeader(header); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
_, err = io.Copy(tarWriter, file)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if walkErr != nil {
|
||||||
|
return nil, walkErr
|
||||||
|
}
|
||||||
|
if err := tarWriter.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := gzipWriter.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return buffer.Bytes(), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package transport
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
pb "github.com/priyanshujain/sanderling/internal/driver/ioscompanion/companionpb"
|
||||||
|
)
|
||||||
|
|
||||||
|
// grpcCompanion must satisfy Companion.
|
||||||
|
var _ Companion = (*grpcCompanion)(nil)
|
||||||
|
|
||||||
|
func TestDialReturnsCompanion(t *testing.T) {
|
||||||
|
companion, err := Dial("127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Dial: %v", err)
|
||||||
|
}
|
||||||
|
defer companion.Close()
|
||||||
|
if companion == nil {
|
||||||
|
t.Fatal("Dial returned nil companion")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessStateFromProto(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in pb.InstalledAppInfo_AppProcessState
|
||||||
|
want ProcessState
|
||||||
|
}{
|
||||||
|
{pb.InstalledAppInfo_RUNNING, ProcessStateRunning},
|
||||||
|
{pb.InstalledAppInfo_NOT_RUNNING, ProcessStateNotRunning},
|
||||||
|
{pb.InstalledAppInfo_UNKNOWN, ProcessStateUnknown},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := processStateFromProto(c.in); got != c.want {
|
||||||
|
t.Errorf("processStateFromProto(%v) = %v, want %v", c.in, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScreenDescriptionFrom(t *testing.T) {
|
||||||
|
resp := &pb.TargetDescriptionResponse{
|
||||||
|
TargetDescription: &pb.TargetDescription{
|
||||||
|
ScreenDimensions: &pb.ScreenDimensions{
|
||||||
|
Width: 828,
|
||||||
|
Height: 1792,
|
||||||
|
Density: 2,
|
||||||
|
WidthPoints: 414,
|
||||||
|
HeightPoints: 896,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
got := screenDescriptionFrom(resp)
|
||||||
|
want := ScreenDescription{WidthPoints: 414, HeightPoints: 896, Scale: 2}
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("screenDescriptionFrom = %+v, want %+v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScreenDescriptionFromNilSafe(t *testing.T) {
|
||||||
|
if got := screenDescriptionFrom(&pb.TargetDescriptionResponse{}); got != (ScreenDescription{}) {
|
||||||
|
t.Errorf("screenDescriptionFrom(empty) = %+v, want zero", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTarGzipDirectory(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
bundle := filepath.Join(root, "Sample.app")
|
||||||
|
if err := os.MkdirAll(filepath.Join(bundle, "PlugIns"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(bundle, "Info.plist"), []byte("plist"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(bundle, "PlugIns", "ext"), []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
archive, err := tarGzipDirectory(bundle)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("tarGzipDirectory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
names := tarEntryNames(t, archive)
|
||||||
|
want := map[string]bool{
|
||||||
|
"Sample.app": true,
|
||||||
|
"Sample.app/Info.plist": true,
|
||||||
|
"Sample.app/PlugIns": true,
|
||||||
|
"Sample.app/PlugIns/ext": true,
|
||||||
|
}
|
||||||
|
for name := range want {
|
||||||
|
if !names[name] {
|
||||||
|
t.Errorf("archive missing entry %q; got %v", name, names)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func tarEntryNames(t *testing.T, archive []byte) map[string]bool {
|
||||||
|
t.Helper()
|
||||||
|
gzipReader, err := gzip.NewReader(bytes.NewReader(archive))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("gzip reader: %v", err)
|
||||||
|
}
|
||||||
|
defer gzipReader.Close()
|
||||||
|
tarReader := tar.NewReader(gzipReader)
|
||||||
|
names := map[string]bool{}
|
||||||
|
for {
|
||||||
|
header, err := tarReader.Next()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("tar next: %v", err)
|
||||||
|
}
|
||||||
|
names[filepath.ToSlash(filepath.Clean(header.Name))] = true
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPayloadChunks(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
dataLength int
|
||||||
|
chunkBytes int
|
||||||
|
wantSizes []int
|
||||||
|
}{
|
||||||
|
{name: "empty", dataLength: 0, chunkBytes: 4, wantSizes: nil},
|
||||||
|
{name: "under one chunk", dataLength: 3, chunkBytes: 4, wantSizes: []int{3}},
|
||||||
|
{name: "exact multiple", dataLength: 8, chunkBytes: 4, wantSizes: []int{4, 4}},
|
||||||
|
{name: "remainder", dataLength: 10, chunkBytes: 4, wantSizes: []int{4, 4, 2}},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
data := make([]byte, c.dataLength)
|
||||||
|
for i := range data {
|
||||||
|
data[i] = byte(i)
|
||||||
|
}
|
||||||
|
chunks := payloadChunks(data, c.chunkBytes)
|
||||||
|
if len(chunks) != len(c.wantSizes) {
|
||||||
|
t.Fatalf("got %d chunks, want %d", len(chunks), len(c.wantSizes))
|
||||||
|
}
|
||||||
|
var rejoined []byte
|
||||||
|
for i, chunk := range chunks {
|
||||||
|
if len(chunk) != c.wantSizes[i] {
|
||||||
|
t.Fatalf("chunk %d size %d, want %d", i, len(chunk), c.wantSizes[i])
|
||||||
|
}
|
||||||
|
rejoined = append(rejoined, chunk...)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(rejoined, data) {
|
||||||
|
t.Fatal("rejoined chunks differ from input")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package transport
|
||||||
|
|
||||||
|
// HIDEventKind discriminates the neutral HID event variants.
|
||||||
|
type HIDEventKind int
|
||||||
|
|
||||||
|
const (
|
||||||
|
HIDKindTouchDown HIDEventKind = iota
|
||||||
|
HIDKindTouchUp
|
||||||
|
HIDKindKeyDown
|
||||||
|
HIDKindKeyUp
|
||||||
|
HIDKindDelay
|
||||||
|
HIDKindSwipe
|
||||||
|
)
|
||||||
|
|
||||||
|
// HIDEvent is one input event in a HID stream, expressed in transport-neutral
|
||||||
|
// terms so each companion transport encodes it for its own wire format. Build
|
||||||
|
// one with a builder below; only the fields for the event's kind are set.
|
||||||
|
type HIDEvent struct {
|
||||||
|
Kind HIDEventKind
|
||||||
|
|
||||||
|
// X, Y is the touch point for TouchDown and TouchUp.
|
||||||
|
X, Y float64
|
||||||
|
|
||||||
|
// Usage is the USB HID usage identifier for KeyDown and KeyUp.
|
||||||
|
Usage uint32
|
||||||
|
|
||||||
|
// Milliseconds is the pause length for Delay.
|
||||||
|
Milliseconds float64
|
||||||
|
|
||||||
|
// FromX through Seconds describe a Swipe.
|
||||||
|
FromX, FromY float64
|
||||||
|
ToX, ToY float64
|
||||||
|
Seconds float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// TouchDown presses a finger down at screen point (x, y).
|
||||||
|
func TouchDown(x, y float64) HIDEvent { return HIDEvent{Kind: HIDKindTouchDown, X: x, Y: y} }
|
||||||
|
|
||||||
|
// TouchUp lifts the finger at screen point (x, y).
|
||||||
|
func TouchUp(x, y float64) HIDEvent { return HIDEvent{Kind: HIDKindTouchUp, X: x, Y: y} }
|
||||||
|
|
||||||
|
// KeyDown presses the key with the given USB HID usage identifier.
|
||||||
|
func KeyDown(usage uint32) HIDEvent { return HIDEvent{Kind: HIDKindKeyDown, Usage: usage} }
|
||||||
|
|
||||||
|
// KeyUp releases the key with the given USB HID usage identifier.
|
||||||
|
func KeyUp(usage uint32) HIDEvent { return HIDEvent{Kind: HIDKindKeyUp, Usage: usage} }
|
||||||
|
|
||||||
|
// Delay pauses the HID stream for the given duration.
|
||||||
|
func Delay(milliseconds float64) HIDEvent {
|
||||||
|
return HIDEvent{Kind: HIDKindDelay, Milliseconds: milliseconds}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SwipeEvent drags from (fromX, fromY) to (toX, toY) over durationSeconds.
|
||||||
|
func SwipeEvent(fromX, fromY, toX, toY float64, durationSeconds float64) HIDEvent {
|
||||||
|
return HIDEvent{
|
||||||
|
Kind: HIDKindSwipe,
|
||||||
|
FromX: fromX,
|
||||||
|
FromY: fromY,
|
||||||
|
ToX: toX,
|
||||||
|
ToY: toY,
|
||||||
|
Seconds: durationSeconds,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package transport
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
pb "github.com/priyanshujain/sanderling/internal/driver/ioscompanion/companionpb"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildersSetKindAndFields(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
event HIDEvent
|
||||||
|
want HIDEvent
|
||||||
|
}{
|
||||||
|
{"touch down", TouchDown(12, 34), HIDEvent{Kind: HIDKindTouchDown, X: 12, Y: 34}},
|
||||||
|
{"touch up", TouchUp(12, 34), HIDEvent{Kind: HIDKindTouchUp, X: 12, Y: 34}},
|
||||||
|
{"key down", KeyDown(225), HIDEvent{Kind: HIDKindKeyDown, Usage: 225}},
|
||||||
|
{"key up", KeyUp(225), HIDEvent{Kind: HIDKindKeyUp, Usage: 225}},
|
||||||
|
{"delay", Delay(250), HIDEvent{Kind: HIDKindDelay, Milliseconds: 250}},
|
||||||
|
{"swipe", SwipeEvent(1, 2, 3, 4, 0.5), HIDEvent{
|
||||||
|
Kind: HIDKindSwipe, FromX: 1, FromY: 2, ToX: 3, ToY: 4, Seconds: 0.5,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
if c.event != c.want {
|
||||||
|
t.Errorf("event = %+v, want %+v", c.event, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTouchEventsToProto(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
event HIDEvent
|
||||||
|
direction pb.HIDEvent_HIDDirection
|
||||||
|
}{
|
||||||
|
{"down", TouchDown(12, 34), pb.HIDEvent_DOWN},
|
||||||
|
{"up", TouchUp(12, 34), pb.HIDEvent_UP},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
message, err := hidEventToProto(c.event)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("hidEventToProto: %v", err)
|
||||||
|
}
|
||||||
|
press := message.GetPress()
|
||||||
|
if press == nil {
|
||||||
|
t.Fatalf("expected press event, got %#v", message.GetEvent())
|
||||||
|
}
|
||||||
|
if press.GetDirection() != c.direction {
|
||||||
|
t.Errorf("direction = %v, want %v", press.GetDirection(), c.direction)
|
||||||
|
}
|
||||||
|
touch := press.GetAction().GetTouch()
|
||||||
|
if touch == nil {
|
||||||
|
t.Fatalf("expected touch action")
|
||||||
|
}
|
||||||
|
if got := touch.GetPoint(); got.GetX() != 12 || got.GetY() != 34 {
|
||||||
|
t.Errorf("point = (%v, %v), want (12, 34)", got.GetX(), got.GetY())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKeyEventsToProto(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
event HIDEvent
|
||||||
|
direction pb.HIDEvent_HIDDirection
|
||||||
|
}{
|
||||||
|
{"down", KeyDown(225), pb.HIDEvent_DOWN},
|
||||||
|
{"up", KeyUp(225), pb.HIDEvent_UP},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
message, err := hidEventToProto(c.event)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("hidEventToProto: %v", err)
|
||||||
|
}
|
||||||
|
press := message.GetPress()
|
||||||
|
if press == nil {
|
||||||
|
t.Fatalf("expected press event")
|
||||||
|
}
|
||||||
|
if press.GetDirection() != c.direction {
|
||||||
|
t.Errorf("direction = %v, want %v", press.GetDirection(), c.direction)
|
||||||
|
}
|
||||||
|
key := press.GetAction().GetKey()
|
||||||
|
if key == nil {
|
||||||
|
t.Fatalf("expected key action")
|
||||||
|
}
|
||||||
|
if key.GetKeycode() != 225 {
|
||||||
|
t.Errorf("keycode = %d, want 225", key.GetKeycode())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDelayToProtoConvertsToSeconds(t *testing.T) {
|
||||||
|
message, err := hidEventToProto(Delay(250))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("hidEventToProto: %v", err)
|
||||||
|
}
|
||||||
|
delay := message.GetDelay()
|
||||||
|
if delay == nil {
|
||||||
|
t.Fatalf("expected delay event")
|
||||||
|
}
|
||||||
|
if delay.GetDuration() != 0.25 {
|
||||||
|
t.Errorf("duration = %v seconds, want 0.25", delay.GetDuration())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwipeEventToProto(t *testing.T) {
|
||||||
|
message, err := hidEventToProto(SwipeEvent(1, 2, 3, 4, 0.5))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("hidEventToProto: %v", err)
|
||||||
|
}
|
||||||
|
swipe := message.GetSwipe()
|
||||||
|
if swipe == nil {
|
||||||
|
t.Fatalf("expected swipe event")
|
||||||
|
}
|
||||||
|
if s := swipe.GetStart(); s.GetX() != 1 || s.GetY() != 2 {
|
||||||
|
t.Errorf("start = (%v, %v), want (1, 2)", s.GetX(), s.GetY())
|
||||||
|
}
|
||||||
|
if e := swipe.GetEnd(); e.GetX() != 3 || e.GetY() != 4 {
|
||||||
|
t.Errorf("end = (%v, %v), want (3, 4)", e.GetX(), e.GetY())
|
||||||
|
}
|
||||||
|
if swipe.GetDuration() != 0.5 {
|
||||||
|
t.Errorf("duration = %v, want 0.5", swipe.GetDuration())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnknownKindToProtoErrors(t *testing.T) {
|
||||||
|
if _, err := hidEventToProto(HIDEvent{Kind: HIDEventKind(99)}); err == nil {
|
||||||
|
t.Fatal("expected error for unknown event kind")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package transport
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestIntegrationAccessibilityInfo spawns a local companion against a booted
|
||||||
|
// simulator and exercises a real AccessibilityInfo call. It is gated behind
|
||||||
|
// SANDERLING_IOS_INTEGRATION so it never runs by default.
|
||||||
|
func TestIntegrationAccessibilityInfo(t *testing.T) {
|
||||||
|
if os.Getenv("SANDERLING_IOS_INTEGRATION") == "" {
|
||||||
|
t.Skip("set SANDERLING_IOS_INTEGRATION=1 to run the integration smoke test")
|
||||||
|
}
|
||||||
|
|
||||||
|
udid := bootedSimulatorUDID(t)
|
||||||
|
port := freePort(t)
|
||||||
|
|
||||||
|
companionBinary := "/opt/homebrew/bin/idb_companion"
|
||||||
|
command := exec.Command(companionBinary, "--udid", udid, "--grpc-port", strconv.Itoa(port))
|
||||||
|
if err := command.Start(); err != nil {
|
||||||
|
t.Fatalf("start companion: %v", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = command.Process.Kill()
|
||||||
|
_, _ = command.Process.Wait()
|
||||||
|
}()
|
||||||
|
|
||||||
|
address := net.JoinHostPort("127.0.0.1", strconv.Itoa(port))
|
||||||
|
waitForListener(t, address)
|
||||||
|
|
||||||
|
companion, err := Dial(address)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Dial: %v", err)
|
||||||
|
}
|
||||||
|
defer companion.Close()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
info, err := companion.AccessibilityInfo(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AccessibilityInfo: %v", err)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(info) == "" {
|
||||||
|
t.Fatal("AccessibilityInfo returned empty json")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func bootedSimulatorUDID(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
output, err := exec.Command("xcrun", "simctl", "list", "devices", "booted", "--json").Output()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("simctl list: %v", err)
|
||||||
|
}
|
||||||
|
var parsed struct {
|
||||||
|
Devices map[string][]struct {
|
||||||
|
UDID string `json:"udid"`
|
||||||
|
State string `json:"state"`
|
||||||
|
} `json:"devices"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(output, &parsed); err != nil {
|
||||||
|
t.Fatalf("parse simctl json: %v", err)
|
||||||
|
}
|
||||||
|
for _, devices := range parsed.Devices {
|
||||||
|
for _, device := range devices {
|
||||||
|
if device.State == "Booted" {
|
||||||
|
return device.UDID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Skip("no booted simulator available")
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func freePort(t *testing.T) int {
|
||||||
|
t.Helper()
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reserve port: %v", err)
|
||||||
|
}
|
||||||
|
port := listener.Addr().(*net.TCPAddr).Port
|
||||||
|
_ = listener.Close()
|
||||||
|
return port
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForListener(t *testing.T, address string) {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(20 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
conn, err := net.DialTimeout("tcp", address, time.Second)
|
||||||
|
if err == nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatalf("companion did not listen on %s", address)
|
||||||
|
}
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
package transport
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// deadlineImmediate is a deadline already in the past, used to interrupt a
|
||||||
|
// blocked read or write when the context is cancelled.
|
||||||
|
var deadlineImmediate = time.Unix(1, 0)
|
||||||
|
|
||||||
|
// runnerCompanion speaks newline-delimited JSON over a single persistent TCP
|
||||||
|
// connection to the in-simulator runner. One request is in flight at a time:
|
||||||
|
// the mutex serializes call/response pairs over the shared connection.
|
||||||
|
type runnerCompanion struct {
|
||||||
|
uniqueDeviceIdentifier string
|
||||||
|
bundleID string
|
||||||
|
address string
|
||||||
|
|
||||||
|
mutex sync.Mutex
|
||||||
|
conn net.Conn
|
||||||
|
reader *bufio.Reader
|
||||||
|
nextID int
|
||||||
|
|
||||||
|
// dirty marks the connection desynced: a call was interrupted before its
|
||||||
|
// response was read, so the next call reconnects to the still-running
|
||||||
|
// server instead of misreading the stale response.
|
||||||
|
dirty bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// DialRunner opens one persistent TCP connection to the simulator runner at
|
||||||
|
// address and returns a Companion that also implements TextEditor. The
|
||||||
|
// uniqueDeviceIdentifier targets simctl shell-outs; bundleID is the app the
|
||||||
|
// snapshot and app-state queries default to.
|
||||||
|
func DialRunner(address, uniqueDeviceIdentifier, bundleID string) (Companion, error) {
|
||||||
|
conn, err := net.Dial("tcp", address)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("runner transport: %w: dial %s: %v", ErrCompanionUnavailable, address, err)
|
||||||
|
}
|
||||||
|
return &runnerCompanion{
|
||||||
|
uniqueDeviceIdentifier: uniqueDeviceIdentifier,
|
||||||
|
bundleID: bundleID,
|
||||||
|
address: address,
|
||||||
|
conn: conn,
|
||||||
|
reader: bufio.NewReader(conn),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *runnerCompanion) Close() error { return c.conn.Close() }
|
||||||
|
|
||||||
|
// runnerRequest and runnerResponse are the wire envelopes. A response carries
|
||||||
|
// either result or error, never both.
|
||||||
|
type runnerRequest struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
Params map[string]any `json:"params"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type runnerResponse struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Result json.RawMessage `json:"result"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// call sends one request and returns its result payload. Transport-level
|
||||||
|
// failures wrap ErrCompanionUnavailable; a server-reported error does not.
|
||||||
|
func (c *runnerCompanion) call(ctx context.Context, method string, params map[string]any) (json.RawMessage, error) {
|
||||||
|
c.mutex.Lock()
|
||||||
|
defer c.mutex.Unlock()
|
||||||
|
|
||||||
|
if c.dirty {
|
||||||
|
if err := c.reconnect(); err != nil {
|
||||||
|
return nil, fmt.Errorf("runner transport: %w: reconnect: %v", ErrCompanionUnavailable, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if params == nil {
|
||||||
|
params = map[string]any{}
|
||||||
|
}
|
||||||
|
c.nextID++
|
||||||
|
id := c.nextID
|
||||||
|
|
||||||
|
// A blocked read or write cannot observe context cancellation directly, so
|
||||||
|
// AfterFunc trips an immediate deadline to interrupt it. The deadline is
|
||||||
|
// cleared once the call returns so the next call starts fresh.
|
||||||
|
stop := context.AfterFunc(ctx, func() {
|
||||||
|
c.conn.SetDeadline(deadlineImmediate)
|
||||||
|
})
|
||||||
|
// Clearing the deadline must happen after stop() so a late-firing AfterFunc
|
||||||
|
// cannot re-arm the deadline once the call has completed. Defers run LIFO.
|
||||||
|
defer c.conn.SetDeadline(time.Time{})
|
||||||
|
defer stop()
|
||||||
|
if deadline, ok := ctx.Deadline(); ok {
|
||||||
|
c.conn.SetDeadline(deadline)
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := json.Marshal(runnerRequest{ID: id, Method: method, Params: params})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("runner transport: %w: marshal %s request: %v", ErrCompanionUnavailable, method, err)
|
||||||
|
}
|
||||||
|
payload = append(payload, '\n')
|
||||||
|
if _, err := c.conn.Write(payload); err != nil {
|
||||||
|
return nil, c.wrapTransport(ctx, "write", method, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
line, err := c.reader.ReadBytes('\n')
|
||||||
|
if err != nil {
|
||||||
|
return nil, c.wrapTransport(ctx, "read", method, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var response runnerResponse
|
||||||
|
if err := json.Unmarshal(line, &response); err != nil {
|
||||||
|
return nil, fmt.Errorf("runner transport: %w: decode %s response: %v", ErrCompanionUnavailable, method, err)
|
||||||
|
}
|
||||||
|
if response.ID != id {
|
||||||
|
return nil, fmt.Errorf("runner transport: %w: response id %d does not match request id %d", ErrCompanionUnavailable, response.ID, id)
|
||||||
|
}
|
||||||
|
if response.Error != "" {
|
||||||
|
return nil, fmt.Errorf("runner %s: %s", method, response.Error)
|
||||||
|
}
|
||||||
|
return response.Result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// wrapTransport classifies a read/write failure. A caller-imposed cancel or
|
||||||
|
// deadline is the caller's slowness budget, not a connection loss, so it does
|
||||||
|
// not carry the unavailable sentinel: a child restart would not make the call
|
||||||
|
// faster. The connection's own deadline is only ever set from the caller's
|
||||||
|
// context, so a deadline-exceeded network error is the same budget expiry
|
||||||
|
// even when it beats the context's done flag by a hair. Either way the
|
||||||
|
// connection is desynced and reconnects on the next call.
|
||||||
|
func (c *runnerCompanion) wrapTransport(ctx context.Context, stage, method string, err error) error {
|
||||||
|
c.dirty = true
|
||||||
|
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||||
|
return fmt.Errorf("runner %s interrupted (%s): %w", method, stage, ctxErr)
|
||||||
|
}
|
||||||
|
if errors.Is(err, os.ErrDeadlineExceeded) {
|
||||||
|
return fmt.Errorf("runner %s interrupted (%s): %w", method, stage, err)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("runner transport: %w: %s %s: %v", ErrCompanionUnavailable, stage, method, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// reconnect replaces the desynced connection with a fresh one to the same
|
||||||
|
// still-running server.
|
||||||
|
func (c *runnerCompanion) reconnect() error {
|
||||||
|
_ = c.conn.Close()
|
||||||
|
conn, err := net.Dial("tcp", c.address)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.conn = conn
|
||||||
|
c.reader = bufio.NewReader(conn)
|
||||||
|
c.dirty = false
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *runnerCompanion) AccessibilityInfo(ctx context.Context) (string, error) {
|
||||||
|
result, err := c.call(ctx, "snapshot", map[string]any{"bundleId": c.bundleID})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
Elements []json.RawMessage `json:"elements"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(result, &payload); err != nil {
|
||||||
|
return "", fmt.Errorf("runner transport: %w: decode snapshot elements: %v", ErrCompanionUnavailable, err)
|
||||||
|
}
|
||||||
|
elements, err := json.Marshal(payload.Elements)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("runner transport: %w: re-marshal snapshot elements: %v", ErrCompanionUnavailable, err)
|
||||||
|
}
|
||||||
|
return string(elements), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *runnerCompanion) Describe(ctx context.Context) (ScreenDescription, error) {
|
||||||
|
result, err := c.call(ctx, "describe", nil)
|
||||||
|
if err != nil {
|
||||||
|
return ScreenDescription{}, err
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
WidthPoints int `json:"widthPoints"`
|
||||||
|
HeightPoints int `json:"heightPoints"`
|
||||||
|
Scale float64 `json:"scale"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(result, &payload); err != nil {
|
||||||
|
return ScreenDescription{}, fmt.Errorf("runner transport: %w: decode describe response: %v", ErrCompanionUnavailable, err)
|
||||||
|
}
|
||||||
|
return ScreenDescription{
|
||||||
|
WidthPoints: payload.WidthPoints,
|
||||||
|
HeightPoints: payload.HeightPoints,
|
||||||
|
Scale: payload.Scale,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *runnerCompanion) SendHID(ctx context.Context, events ...HIDEvent) error {
|
||||||
|
encoded := make([]map[string]any, 0, len(events))
|
||||||
|
for _, event := range events {
|
||||||
|
object, err := hidEventToObject(event)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
encoded = append(encoded, object)
|
||||||
|
}
|
||||||
|
_, err := c.call(ctx, "gesture", map[string]any{"events": encoded})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// hidEventToObject encodes a neutral HID event as a runner gesture event. The
|
||||||
|
// runner edits text natively, so keyboard HID events are rejected with an
|
||||||
|
// ordinary error rather than a connection-level one.
|
||||||
|
func hidEventToObject(event HIDEvent) (map[string]any, error) {
|
||||||
|
switch event.Kind {
|
||||||
|
case HIDKindTouchDown:
|
||||||
|
return map[string]any{"kind": "touchDown", "x": event.X, "y": event.Y}, nil
|
||||||
|
case HIDKindTouchUp:
|
||||||
|
return map[string]any{"kind": "touchUp", "x": event.X, "y": event.Y}, nil
|
||||||
|
case HIDKindDelay:
|
||||||
|
return map[string]any{"kind": "delay", "milliseconds": event.Milliseconds}, nil
|
||||||
|
case HIDKindSwipe:
|
||||||
|
return map[string]any{
|
||||||
|
"kind": "swipe",
|
||||||
|
"fromX": event.FromX,
|
||||||
|
"fromY": event.FromY,
|
||||||
|
"toX": event.ToX,
|
||||||
|
"toY": event.ToY,
|
||||||
|
"seconds": event.Seconds,
|
||||||
|
}, nil
|
||||||
|
case HIDKindKeyDown, HIDKindKeyUp:
|
||||||
|
return nil, errors.New("runner companion does not synthesize keyboard HID events; route text through the text editor")
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("unknown HID event kind %d", event.Kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *runnerCompanion) Screenshot(ctx context.Context) ([]byte, string, error) {
|
||||||
|
result, err := c.call(ctx, "screenshot", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
PNGBase64 string `json:"pngBase64"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(result, &payload); err != nil {
|
||||||
|
return nil, "", fmt.Errorf("runner transport: %w: decode screenshot response: %v", ErrCompanionUnavailable, err)
|
||||||
|
}
|
||||||
|
data, err := base64.StdEncoding.DecodeString(payload.PNGBase64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("runner transport: %w: decode screenshot png: %v", ErrCompanionUnavailable, err)
|
||||||
|
}
|
||||||
|
return data, "png", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *runnerCompanion) Launch(ctx context.Context, bundleID string, foregroundIfRunning bool) error {
|
||||||
|
_, err := c.call(ctx, "launch", map[string]any{
|
||||||
|
"bundleId": bundleID,
|
||||||
|
"foregroundIfRunning": foregroundIfRunning,
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *runnerCompanion) Terminate(ctx context.Context, bundleID string) error {
|
||||||
|
_, err := c.call(ctx, "terminate", map[string]any{"bundleId": bundleID})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *runnerCompanion) ListApps(ctx context.Context) ([]InstalledApp, error) {
|
||||||
|
result, err := c.call(ctx, "appState", map[string]any{"bundleId": c.bundleID})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
State string `json:"state"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(result, &payload); err != nil {
|
||||||
|
return nil, fmt.Errorf("runner transport: %w: decode appState response: %v", ErrCompanionUnavailable, err)
|
||||||
|
}
|
||||||
|
return []InstalledApp{{
|
||||||
|
BundleID: c.bundleID,
|
||||||
|
InstallType: "user",
|
||||||
|
ProcessState: processStateFromAppState(payload.State),
|
||||||
|
}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func processStateFromAppState(state string) ProcessState {
|
||||||
|
switch state {
|
||||||
|
case "foreground", "background":
|
||||||
|
return ProcessStateRunning
|
||||||
|
case "notRunning":
|
||||||
|
return ProcessStateNotRunning
|
||||||
|
default:
|
||||||
|
return ProcessStateUnknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install and Uninstall shell out to simctl. The driver does not call these
|
||||||
|
// today; they complete the Companion interface.
|
||||||
|
func (c *runnerCompanion) Install(ctx context.Context, appPath string) error {
|
||||||
|
command := exec.CommandContext(ctx, "xcrun", "simctl", "install", c.uniqueDeviceIdentifier, appPath)
|
||||||
|
if output, err := command.CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("simctl install: %v: %s", err, output)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *runnerCompanion) Uninstall(ctx context.Context, bundleID string) error {
|
||||||
|
command := exec.CommandContext(ctx, "xcrun", "simctl", "uninstall", c.uniqueDeviceIdentifier, bundleID)
|
||||||
|
if output, err := command.CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("simctl uninstall: %v: %s", err, output)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *runnerCompanion) InputText(ctx context.Context, text string) error {
|
||||||
|
return c.TypeText(ctx, text, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *runnerCompanion) TypeText(ctx context.Context, text string, replace bool) error {
|
||||||
|
_, err := c.call(ctx, "typeText", map[string]any{"text": text, "replace": replace})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *runnerCompanion) EraseText(ctx context.Context, characterCount int) error {
|
||||||
|
_, err := c.call(ctx, "eraseText", map[string]any{"count": characterCount})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *runnerCompanion) PressKey(ctx context.Context, key string) error {
|
||||||
|
switch key {
|
||||||
|
case "enter", "return", "Enter", "Return":
|
||||||
|
_, err := c.call(ctx, "pressKey", map[string]any{"key": "return"})
|
||||||
|
return err
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("runner companion cannot press key %q; only return is supported", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,556 @@
|
|||||||
|
package transport
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"reflect"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
_ Companion = (*runnerCompanion)(nil)
|
||||||
|
_ TextEditor = (*runnerCompanion)(nil)
|
||||||
|
)
|
||||||
|
|
||||||
|
// scriptedReply maps a method name to the raw JSON object the fake server writes
|
||||||
|
// back as the "result" field. A method absent from the script gets an empty
|
||||||
|
// object result.
|
||||||
|
type scriptedReply map[string]string
|
||||||
|
|
||||||
|
// fakeServer is an in-process runner stand-in. It accepts a single connection,
|
||||||
|
// records every decoded request, and answers from a scripted table.
|
||||||
|
type fakeServer struct {
|
||||||
|
listener net.Listener
|
||||||
|
address string
|
||||||
|
|
||||||
|
mutex sync.Mutex
|
||||||
|
requests []runnerRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
func startFakeServer(t *testing.T, script scriptedReply) *fakeServer {
|
||||||
|
t.Helper()
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listen: %v", err)
|
||||||
|
}
|
||||||
|
server := &fakeServer{listener: listener, address: listener.Addr().String()}
|
||||||
|
go server.serve(script)
|
||||||
|
t.Cleanup(func() { listener.Close() })
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fakeServer) serve(script scriptedReply) {
|
||||||
|
conn, err := s.listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
reader := bufio.NewReader(conn)
|
||||||
|
for {
|
||||||
|
line, err := reader.ReadBytes('\n')
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var request runnerRequest
|
||||||
|
if err := json.Unmarshal(line, &request); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.mutex.Lock()
|
||||||
|
s.requests = append(s.requests, request)
|
||||||
|
s.mutex.Unlock()
|
||||||
|
|
||||||
|
result := script[request.Method]
|
||||||
|
if result == "" {
|
||||||
|
result = "{}"
|
||||||
|
}
|
||||||
|
response := `{"id":` + strconv.Itoa(request.ID) + `,"result":` + result + "}\n"
|
||||||
|
if _, err := conn.Write([]byte(response)); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fakeServer) recorded() []runnerRequest {
|
||||||
|
s.mutex.Lock()
|
||||||
|
defer s.mutex.Unlock()
|
||||||
|
out := make([]runnerRequest, len(s.requests))
|
||||||
|
copy(out, s.requests)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func dialFake(t *testing.T, server *fakeServer, bundleID string) Companion {
|
||||||
|
t.Helper()
|
||||||
|
companion, err := DialRunner(server.address, "UDID-1234", bundleID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DialRunner: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { companion.Close() })
|
||||||
|
return companion
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSnapshotReMarshalsElements(t *testing.T) {
|
||||||
|
server := startFakeServer(t, scriptedReply{
|
||||||
|
"snapshot": `{"elements":[{"role":"button"},{"role":"text"}]}`,
|
||||||
|
})
|
||||||
|
companion := dialFake(t, server, "com.example.app")
|
||||||
|
|
||||||
|
got, err := companion.AccessibilityInfo(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AccessibilityInfo: %v", err)
|
||||||
|
}
|
||||||
|
want := `[{"role":"button"},{"role":"text"}]`
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("elements = %s, want %s", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
requests := server.recorded()
|
||||||
|
if len(requests) != 1 {
|
||||||
|
t.Fatalf("recorded %d requests, want 1", len(requests))
|
||||||
|
}
|
||||||
|
if requests[0].Method != "snapshot" {
|
||||||
|
t.Fatalf("method = %s, want snapshot", requests[0].Method)
|
||||||
|
}
|
||||||
|
if requests[0].Params["bundleId"] != "com.example.app" {
|
||||||
|
t.Fatalf("bundleId = %v, want com.example.app", requests[0].Params["bundleId"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGestureEncodesDoubleTapStream(t *testing.T) {
|
||||||
|
server := startFakeServer(t, nil)
|
||||||
|
companion := dialFake(t, server, "com.example.app")
|
||||||
|
|
||||||
|
err := companion.SendHID(context.Background(),
|
||||||
|
TouchDown(10, 20),
|
||||||
|
TouchUp(10, 20),
|
||||||
|
Delay(60),
|
||||||
|
TouchDown(10, 20),
|
||||||
|
TouchUp(10, 20),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SendHID: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
requests := server.recorded()
|
||||||
|
if len(requests) != 1 || requests[0].Method != "gesture" {
|
||||||
|
t.Fatalf("requests = %+v, want one gesture", requests)
|
||||||
|
}
|
||||||
|
events, ok := requests[0].Params["events"].([]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("events not an array: %T", requests[0].Params["events"])
|
||||||
|
}
|
||||||
|
want := []map[string]any{
|
||||||
|
{"kind": "touchDown", "x": 10.0, "y": 20.0},
|
||||||
|
{"kind": "touchUp", "x": 10.0, "y": 20.0},
|
||||||
|
{"kind": "delay", "milliseconds": 60.0},
|
||||||
|
{"kind": "touchDown", "x": 10.0, "y": 20.0},
|
||||||
|
{"kind": "touchUp", "x": 10.0, "y": 20.0},
|
||||||
|
}
|
||||||
|
if len(events) != len(want) {
|
||||||
|
t.Fatalf("got %d events, want %d", len(events), len(want))
|
||||||
|
}
|
||||||
|
for index, event := range events {
|
||||||
|
if !reflect.DeepEqual(event, map[string]any(want[index])) {
|
||||||
|
t.Fatalf("event %d = %v, want %v", index, event, want[index])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendHIDRejectsKeyEvents(t *testing.T) {
|
||||||
|
server := startFakeServer(t, nil)
|
||||||
|
companion := dialFake(t, server, "com.example.app")
|
||||||
|
|
||||||
|
err := companion.SendHID(context.Background(), KeyDown(4), KeyUp(4))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for key HID events")
|
||||||
|
}
|
||||||
|
if errors.Is(err, ErrCompanionUnavailable) {
|
||||||
|
t.Fatalf("key rejection should not be a transport error: %v", err)
|
||||||
|
}
|
||||||
|
if requests := server.recorded(); len(requests) != 0 {
|
||||||
|
t.Fatalf("expected nothing sent, got %+v", requests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScreenshotBase64RoundTrip(t *testing.T) {
|
||||||
|
original := []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a}
|
||||||
|
encoded := base64.StdEncoding.EncodeToString(original)
|
||||||
|
server := startFakeServer(t, scriptedReply{
|
||||||
|
"screenshot": `{"pngBase64":"` + encoded + `"}`,
|
||||||
|
})
|
||||||
|
companion := dialFake(t, server, "com.example.app")
|
||||||
|
|
||||||
|
data, format, err := companion.Screenshot(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Screenshot: %v", err)
|
||||||
|
}
|
||||||
|
if format != "png" {
|
||||||
|
t.Fatalf("format = %s, want png", format)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(data, original) {
|
||||||
|
t.Fatalf("data = %v, want %v", data, original)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDescribeMapsScreenDescription(t *testing.T) {
|
||||||
|
server := startFakeServer(t, scriptedReply{
|
||||||
|
"describe": `{"widthPoints":390,"heightPoints":844,"scale":3.0}`,
|
||||||
|
})
|
||||||
|
companion := dialFake(t, server, "com.example.app")
|
||||||
|
|
||||||
|
description, err := companion.Describe(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Describe: %v", err)
|
||||||
|
}
|
||||||
|
want := ScreenDescription{WidthPoints: 390, HeightPoints: 844, Scale: 3.0}
|
||||||
|
if description != want {
|
||||||
|
t.Fatalf("description = %+v, want %+v", description, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTextEditorMethods(t *testing.T) {
|
||||||
|
server := startFakeServer(t, nil)
|
||||||
|
companion := dialFake(t, server, "com.example.app")
|
||||||
|
editor := companion.(TextEditor)
|
||||||
|
|
||||||
|
if err := editor.InputText(context.Background(), "hello"); err != nil {
|
||||||
|
t.Fatalf("InputText: %v", err)
|
||||||
|
}
|
||||||
|
if err := editor.EraseText(context.Background(), 3); err != nil {
|
||||||
|
t.Fatalf("EraseText: %v", err)
|
||||||
|
}
|
||||||
|
if err := editor.PressKey(context.Background(), "Enter"); err != nil {
|
||||||
|
t.Fatalf("PressKey: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
requests := server.recorded()
|
||||||
|
if len(requests) != 3 {
|
||||||
|
t.Fatalf("recorded %d requests, want 3", len(requests))
|
||||||
|
}
|
||||||
|
if requests[0].Method != "typeText" || requests[0].Params["text"] != "hello" || requests[0].Params["replace"] != true {
|
||||||
|
t.Fatalf("typeText request = %+v", requests[0])
|
||||||
|
}
|
||||||
|
if requests[1].Method != "eraseText" || requests[1].Params["count"] != 3.0 {
|
||||||
|
t.Fatalf("eraseText request = %+v", requests[1])
|
||||||
|
}
|
||||||
|
if requests[2].Method != "pressKey" || requests[2].Params["key"] != "return" {
|
||||||
|
t.Fatalf("pressKey request = %+v", requests[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPressKeyRejectsUnknownKey(t *testing.T) {
|
||||||
|
server := startFakeServer(t, nil)
|
||||||
|
companion := dialFake(t, server, "com.example.app")
|
||||||
|
editor := companion.(TextEditor)
|
||||||
|
|
||||||
|
err := editor.PressKey(context.Background(), "home")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for unsupported key")
|
||||||
|
}
|
||||||
|
if errors.Is(err, ErrCompanionUnavailable) {
|
||||||
|
t.Fatalf("unsupported key should not be a transport error: %v", err)
|
||||||
|
}
|
||||||
|
if requests := server.recorded(); len(requests) != 0 {
|
||||||
|
t.Fatalf("expected nothing sent, got %+v", requests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLaunchTerminateMapping(t *testing.T) {
|
||||||
|
server := startFakeServer(t, nil)
|
||||||
|
companion := dialFake(t, server, "com.example.app")
|
||||||
|
|
||||||
|
if err := companion.Launch(context.Background(), "com.example.target", true); err != nil {
|
||||||
|
t.Fatalf("Launch: %v", err)
|
||||||
|
}
|
||||||
|
if err := companion.Terminate(context.Background(), "com.example.target"); err != nil {
|
||||||
|
t.Fatalf("Terminate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
requests := server.recorded()
|
||||||
|
if len(requests) != 2 {
|
||||||
|
t.Fatalf("recorded %d requests, want 2", len(requests))
|
||||||
|
}
|
||||||
|
if requests[0].Method != "launch" ||
|
||||||
|
requests[0].Params["bundleId"] != "com.example.target" ||
|
||||||
|
requests[0].Params["foregroundIfRunning"] != true {
|
||||||
|
t.Fatalf("launch request = %+v", requests[0])
|
||||||
|
}
|
||||||
|
if requests[1].Method != "terminate" || requests[1].Params["bundleId"] != "com.example.target" {
|
||||||
|
t.Fatalf("terminate request = %+v", requests[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListAppsStateMapping(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
state string
|
||||||
|
want ProcessState
|
||||||
|
}{
|
||||||
|
{"foreground", ProcessStateRunning},
|
||||||
|
{"background", ProcessStateRunning},
|
||||||
|
{"notRunning", ProcessStateNotRunning},
|
||||||
|
{"unknown", ProcessStateUnknown},
|
||||||
|
}
|
||||||
|
for _, testCase := range cases {
|
||||||
|
server := startFakeServer(t, scriptedReply{
|
||||||
|
"appState": `{"state":"` + testCase.state + `"}`,
|
||||||
|
})
|
||||||
|
companion := dialFake(t, server, "com.example.app")
|
||||||
|
|
||||||
|
apps, err := companion.ListApps(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListApps(%s): %v", testCase.state, err)
|
||||||
|
}
|
||||||
|
if len(apps) != 1 {
|
||||||
|
t.Fatalf("ListApps(%s) returned %d apps, want 1", testCase.state, len(apps))
|
||||||
|
}
|
||||||
|
app := apps[0]
|
||||||
|
if app.BundleID != "com.example.app" || app.InstallType != "user" {
|
||||||
|
t.Fatalf("app fields = %+v", app)
|
||||||
|
}
|
||||||
|
if app.ProcessState != testCase.want {
|
||||||
|
t.Fatalf("state %s -> %v, want %v", testCase.state, app.ProcessState, testCase.want)
|
||||||
|
}
|
||||||
|
|
||||||
|
requests := server.recorded()
|
||||||
|
if len(requests) != 1 || requests[0].Method != "appState" || requests[0].Params["bundleId"] != "com.example.app" {
|
||||||
|
t.Fatalf("appState request = %+v", requests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerErrorIsNotSentinel(t *testing.T) {
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listen: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { listener.Close() })
|
||||||
|
go func() {
|
||||||
|
conn, err := listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
reader := bufio.NewReader(conn)
|
||||||
|
line, err := reader.ReadBytes('\n')
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var request runnerRequest
|
||||||
|
json.Unmarshal(line, &request)
|
||||||
|
conn.Write([]byte(`{"id":` + strconv.Itoa(request.ID) + `,"error":"field not focused"}` + "\n"))
|
||||||
|
}()
|
||||||
|
|
||||||
|
companion, err := DialRunner(listener.Addr().String(), "UDID", "com.example.app")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DialRunner: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { companion.Close() })
|
||||||
|
|
||||||
|
_, err = companion.Describe(context.Background())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected server error")
|
||||||
|
}
|
||||||
|
if errors.Is(err, ErrCompanionUnavailable) {
|
||||||
|
t.Fatalf("server error must not wrap the sentinel: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerClosesMidCallIsSentinel(t *testing.T) {
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listen: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { listener.Close() })
|
||||||
|
go func() {
|
||||||
|
conn, err := listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Read the request, then drop the connection without replying.
|
||||||
|
bufio.NewReader(conn).ReadBytes('\n')
|
||||||
|
conn.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
companion, err := DialRunner(listener.Addr().String(), "UDID", "com.example.app")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DialRunner: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { companion.Close() })
|
||||||
|
|
||||||
|
_, err = companion.Describe(context.Background())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when server closes mid-call")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrCompanionUnavailable) {
|
||||||
|
t.Fatalf("dropped connection must wrap the sentinel: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContextCancellationUnblocksCall(t *testing.T) {
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listen: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { listener.Close() })
|
||||||
|
go func() {
|
||||||
|
conn, err := listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Accept and hold the connection open, never replying.
|
||||||
|
bufio.NewReader(conn).ReadBytes('\n')
|
||||||
|
<-make(chan struct{})
|
||||||
|
}()
|
||||||
|
|
||||||
|
companion, err := DialRunner(listener.Addr().String(), "UDID", "com.example.app")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DialRunner: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { companion.Close() })
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
time.AfterFunc(50*time.Millisecond, cancel)
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
_, callErr := companion.Describe(ctx)
|
||||||
|
done <- callErr
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case callErr := <-done:
|
||||||
|
if callErr == nil {
|
||||||
|
t.Fatal("expected cancellation error")
|
||||||
|
}
|
||||||
|
// A caller-imposed cancel is the caller's budget, not a connection
|
||||||
|
// loss: it must NOT wrap the sentinel, or a slow call would trigger a
|
||||||
|
// pointless child restart.
|
||||||
|
if !errors.Is(callErr, context.Canceled) {
|
||||||
|
t.Fatalf("cancellation error must carry the context error: %v", callErr)
|
||||||
|
}
|
||||||
|
if errors.Is(callErr, ErrCompanionUnavailable) {
|
||||||
|
t.Fatalf("cancellation error must not wrap the sentinel: %v", callErr)
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("cancelled call did not return within 2s")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInterruptedCallReconnectsOnNextCall(t *testing.T) {
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listen: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { listener.Close() })
|
||||||
|
|
||||||
|
// The server holds "describe" hostage and answers everything else, on
|
||||||
|
// every connection it accepts. A late reply to the interrupted request
|
||||||
|
// must never be misread by the following call.
|
||||||
|
accepted := make(chan net.Conn, 4)
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
conn, acceptErr := listener.Accept()
|
||||||
|
if acceptErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
accepted <- conn
|
||||||
|
go func(c net.Conn) {
|
||||||
|
reader := bufio.NewReader(c)
|
||||||
|
for {
|
||||||
|
line, readErr := reader.ReadBytes('\n')
|
||||||
|
if readErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var request runnerRequest
|
||||||
|
if json.Unmarshal(line, &request) != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if request.Method == "describe" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
response := `{"id":` + strconv.Itoa(request.ID) + `,"result":{"ok":true}}` + "\n"
|
||||||
|
if _, writeErr := c.Write([]byte(response)); writeErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(conn)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
companion, err := DialRunner(listener.Addr().String(), "UDID", "com.example.app")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DialRunner: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { companion.Close() })
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
if _, err := companion.Describe(ctx); err == nil {
|
||||||
|
t.Fatal("expected the held call to time out")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The next call must transparently reconnect and succeed.
|
||||||
|
if err := companion.Terminate(context.Background(), "com.example.app"); err != nil {
|
||||||
|
t.Fatalf("call after interrupt: %v", err)
|
||||||
|
}
|
||||||
|
if len(accepted) != 2 {
|
||||||
|
t.Fatalf("accepted %d connections, want 2 (reconnect)", len(accepted))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResponseIDMismatchIsSentinel(t *testing.T) {
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listen: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { listener.Close() })
|
||||||
|
go func() {
|
||||||
|
conn, err := listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
bufio.NewReader(conn).ReadBytes('\n')
|
||||||
|
// Reply with an id that cannot match any request.
|
||||||
|
conn.Write([]byte(`{"id":9999,"result":{}}` + "\n"))
|
||||||
|
}()
|
||||||
|
|
||||||
|
companion, err := DialRunner(listener.Addr().String(), "UDID", "com.example.app")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DialRunner: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { companion.Close() })
|
||||||
|
|
||||||
|
_, err = companion.Describe(context.Background())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected id-mismatch error")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrCompanionUnavailable) {
|
||||||
|
t.Fatalf("id mismatch must wrap the sentinel: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTypeTextAppendsWithoutReplace(t *testing.T) {
|
||||||
|
server := startFakeServer(t, nil)
|
||||||
|
companion := dialFake(t, server, "com.example.app")
|
||||||
|
typer := companion.(TextTyper)
|
||||||
|
|
||||||
|
if err := typer.TypeText(context.Background(), "héllo 🌟", false); err != nil {
|
||||||
|
t.Fatalf("TypeText: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
requests := server.recorded()
|
||||||
|
if len(requests) != 1 {
|
||||||
|
t.Fatalf("recorded %d requests, want 1", len(requests))
|
||||||
|
}
|
||||||
|
request := requests[0]
|
||||||
|
if request.Method != "typeText" || request.Params["text"] != "héllo 🌟" || request.Params["replace"] != false {
|
||||||
|
t.Fatalf("typeText request = %+v", request)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
// Package transport is the only layer that touches the generated companion
|
||||||
|
// gRPC stubs. Everything above it speaks the brand-free Companion interface,
|
||||||
|
// so the companion binary stays swappable behind this boundary.
|
||||||
|
package transport
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
pb "github.com/priyanshujain/sanderling/internal/driver/ioscompanion/companionpb"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Companion drives a single booted iOS simulator through the companion.
|
||||||
|
// Callers own per-call deadlines by passing a context.
|
||||||
|
type Companion interface {
|
||||||
|
// AccessibilityInfo returns the describe-all accessibility tree as the
|
||||||
|
// raw flat-format JSON string the companion emits.
|
||||||
|
AccessibilityInfo(ctx context.Context) (string, error)
|
||||||
|
|
||||||
|
// Describe reports the target's screen dimensions in points, along with
|
||||||
|
// the pixel scale when the companion supplies one.
|
||||||
|
Describe(ctx context.Context) (ScreenDescription, error)
|
||||||
|
|
||||||
|
// SendHID opens the HID stream, sends every event in order, then closes.
|
||||||
|
SendHID(ctx context.Context, events ...HIDEvent) error
|
||||||
|
|
||||||
|
// Screenshot captures the current screen, returning the encoded image
|
||||||
|
// bytes and the image format string the companion reports.
|
||||||
|
Screenshot(ctx context.Context) (imageData []byte, imageFormat string, err error)
|
||||||
|
|
||||||
|
// Launch brings the app to the foreground, starting it if needed.
|
||||||
|
Launch(ctx context.Context, bundleID string, foregroundIfRunning bool) error
|
||||||
|
|
||||||
|
// Terminate stops the running app with the given bundle identifier.
|
||||||
|
Terminate(ctx context.Context, bundleID string) error
|
||||||
|
|
||||||
|
// ListApps reports every installed app and its current process state.
|
||||||
|
ListApps(ctx context.Context) ([]InstalledApp, error)
|
||||||
|
|
||||||
|
// Install installs a .app bundle directory by streaming it to the
|
||||||
|
// companion.
|
||||||
|
Install(ctx context.Context, appPath string) error
|
||||||
|
|
||||||
|
// Uninstall removes the app with the given bundle identifier.
|
||||||
|
Uninstall(ctx context.Context, bundleID string) error
|
||||||
|
|
||||||
|
// Close releases the underlying connection.
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// TextEditor is an optional companion capability: the transport edits text
|
||||||
|
// natively on the device instead of the driver composing keyboard HID streams.
|
||||||
|
// The driver routes text input through it when the companion implements it.
|
||||||
|
type TextEditor interface {
|
||||||
|
// InputText replaces the focused field's content with text.
|
||||||
|
InputText(ctx context.Context, text string) error
|
||||||
|
|
||||||
|
// EraseText deletes characterCount characters from the focused field.
|
||||||
|
EraseText(ctx context.Context, characterCount int) error
|
||||||
|
|
||||||
|
// PressKey presses the named logical key (currently only return/enter).
|
||||||
|
PressKey(ctx context.Context, key string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// TextTyper is an optional companion capability: the transport types text
|
||||||
|
// natively into whatever holds keyboard focus. Unlike TextEditor it exposes
|
||||||
|
// the replace flag, so a caller can clear the field through another channel
|
||||||
|
// and append with replace false.
|
||||||
|
type TextTyper interface {
|
||||||
|
TypeText(ctx context.Context, text string, replace bool) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrCompanionUnavailable marks a connection-level failure that a companion
|
||||||
|
// restart can recover from. Transports wrap dropped-connection errors with it.
|
||||||
|
var ErrCompanionUnavailable = errors.New("companion connection unavailable")
|
||||||
|
|
||||||
|
// ScreenDescription carries the target screen geometry. Width and Height are
|
||||||
|
// in points (the coordinate space HID events and the accessibility frames use).
|
||||||
|
// Scale is the pixel-per-point density, or 0 when the companion did not report
|
||||||
|
// one.
|
||||||
|
type ScreenDescription struct {
|
||||||
|
WidthPoints int
|
||||||
|
HeightPoints int
|
||||||
|
Scale float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProcessState mirrors the companion's notion of whether an app is running.
|
||||||
|
type ProcessState int
|
||||||
|
|
||||||
|
const (
|
||||||
|
ProcessStateUnknown ProcessState = iota
|
||||||
|
ProcessStateNotRunning
|
||||||
|
ProcessStateRunning
|
||||||
|
)
|
||||||
|
|
||||||
|
// InstalledApp describes one installed app. ProcessState and ProcessIdentifier
|
||||||
|
// together answer whether the app is currently running and in the foreground.
|
||||||
|
type InstalledApp struct {
|
||||||
|
BundleID string
|
||||||
|
Name string
|
||||||
|
InstallType string
|
||||||
|
ProcessState ProcessState
|
||||||
|
Debuggable bool
|
||||||
|
ProcessIdentifier uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
func processStateFromProto(s pb.InstalledAppInfo_AppProcessState) ProcessState {
|
||||||
|
switch s {
|
||||||
|
case pb.InstalledAppInfo_RUNNING:
|
||||||
|
return ProcessStateRunning
|
||||||
|
case pb.InstalledAppInfo_NOT_RUNNING:
|
||||||
|
return ProcessStateNotRunning
|
||||||
|
default:
|
||||||
|
return ProcessStateUnknown
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ type simctlDeviceList struct {
|
|||||||
// with canned device lists without invoking xcrun.
|
// with canned device lists without invoking xcrun.
|
||||||
var (
|
var (
|
||||||
listBooted = bootedSimulator
|
listBooted = bootedSimulator
|
||||||
|
listBootedAll = bootedSimulators
|
||||||
listAvailable = availableSimulators
|
listAvailable = availableSimulators
|
||||||
boot = bootSimulator
|
boot = bootSimulator
|
||||||
waitForBoot = waitForSimulatorBoot
|
waitForBoot = waitForSimulatorBoot
|
||||||
@@ -73,6 +74,59 @@ func BootedUDID(ctx context.Context) string {
|
|||||||
return d.UDID
|
return d.UDID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResolveTarget decides which iOS target a run drives and whether it is a
|
||||||
|
// simulator. An explicit query matching a simulator name or UDID (booted or
|
||||||
|
// available) resolves to that simulator. With no query, exactly one booted
|
||||||
|
// simulator resolves to it; multiple booted simulators is an error that lists
|
||||||
|
// them and asks the caller to pass --ios-device. A query that matches no
|
||||||
|
// simulator resolves as a physical device (udid = query, simulator false) so
|
||||||
|
// the sidecar path drives it.
|
||||||
|
func ResolveTarget(ctx context.Context, query string) (udid string, isSimulator bool, err error) {
|
||||||
|
if query != "" {
|
||||||
|
booted, err := listBootedAll(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, fmt.Errorf("list booted simulators: %w", err)
|
||||||
|
}
|
||||||
|
if match := matchSimulator(query, booted); match != nil {
|
||||||
|
return match.UDID, true, nil
|
||||||
|
}
|
||||||
|
available, err := listAvailable(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, fmt.Errorf("list available simulators: %w", err)
|
||||||
|
}
|
||||||
|
if match := matchSimulator(query, available); match != nil {
|
||||||
|
return match.UDID, true, nil
|
||||||
|
}
|
||||||
|
return query, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
booted, err := listBootedAll(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, fmt.Errorf("list booted simulators: %w", err)
|
||||||
|
}
|
||||||
|
switch len(booted) {
|
||||||
|
case 1:
|
||||||
|
return booted[0].UDID, true, nil
|
||||||
|
case 0:
|
||||||
|
return "", false, fmt.Errorf("no booted iOS simulator found")
|
||||||
|
default:
|
||||||
|
var lines strings.Builder
|
||||||
|
for _, device := range booted {
|
||||||
|
fmt.Fprintf(&lines, "\n %s (%s)", device.Name, device.UDID)
|
||||||
|
}
|
||||||
|
return "", false, fmt.Errorf("multiple booted iOS simulators; pass --ios-device to select one:%s", lines.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchSimulator(query string, devices []simDevice) *simDevice {
|
||||||
|
for i := range devices {
|
||||||
|
if devices[i].Name == query || devices[i].UDID == query {
|
||||||
|
return &devices[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func bootedSimulator(ctx context.Context) (*simDevice, error) {
|
func bootedSimulator(ctx context.Context) (*simDevice, error) {
|
||||||
out, err := exec.CommandContext(ctx, "xcrun", "simctl", "list", "devices", "booted", "--json").Output()
|
out, err := exec.CommandContext(ctx, "xcrun", "simctl", "list", "devices", "booted", "--json").Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -81,6 +135,14 @@ func bootedSimulator(ctx context.Context) (*simDevice, error) {
|
|||||||
return parseBootedDevice(out)
|
return parseBootedDevice(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func bootedSimulators(ctx context.Context) ([]simDevice, error) {
|
||||||
|
out, err := exec.CommandContext(ctx, "xcrun", "simctl", "list", "devices", "booted", "--json").Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return parseBootedDevices(out)
|
||||||
|
}
|
||||||
|
|
||||||
func availableSimulators(ctx context.Context) ([]simDevice, error) {
|
func availableSimulators(ctx context.Context) ([]simDevice, error) {
|
||||||
out, err := exec.CommandContext(ctx, "xcrun", "simctl", "list", "devices", "available", "--json").Output()
|
out, err := exec.CommandContext(ctx, "xcrun", "simctl", "list", "devices", "available", "--json").Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -104,6 +166,22 @@ func parseBootedDevice(out []byte) (*simDevice, error) {
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseBootedDevices(out []byte) ([]simDevice, error) {
|
||||||
|
var list simctlDeviceList
|
||||||
|
if err := json.Unmarshal(out, &list); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var result []simDevice
|
||||||
|
for _, devices := range list.Devices {
|
||||||
|
for i := range devices {
|
||||||
|
if devices[i].State == "Booted" {
|
||||||
|
result = append(result, devices[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
func parseAvailableDevices(out []byte) ([]simDevice, error) {
|
func parseAvailableDevices(out []byte) ([]simDevice, error) {
|
||||||
var list simctlDeviceList
|
var list simctlDeviceList
|
||||||
if err := json.Unmarshal(out, &list); err != nil {
|
if err := json.Unmarshal(out, &list); err != nil {
|
||||||
|
|||||||
@@ -208,6 +208,125 @@ func TestEnsureSimulator_BootsPickedSimulator(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func swapResolveSeams(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
origBootedAll, origAvailable := listBootedAll, listAvailable
|
||||||
|
t.Cleanup(func() { listBootedAll, listAvailable = origBootedAll, origAvailable })
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveTarget_ExplicitMatchesBootedSimulator(t *testing.T) {
|
||||||
|
swapResolveSeams(t)
|
||||||
|
listBootedAll = func(context.Context) ([]simDevice, error) {
|
||||||
|
return []simDevice{{UDID: "booted-udid", Name: "iPhone 15", State: "Booted", IsAvailable: true}}, nil
|
||||||
|
}
|
||||||
|
listAvailable = func(context.Context) ([]simDevice, error) {
|
||||||
|
t.Fatal("should not list available when a booted simulator matches")
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
udid, isSimulator, err := ResolveTarget(context.Background(), "iPhone 15")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if udid != "booted-udid" || !isSimulator {
|
||||||
|
t.Errorf("got (%q, %v), want (booted-udid, true)", udid, isSimulator)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveTarget_ExplicitMatchesAvailableByUDID(t *testing.T) {
|
||||||
|
swapResolveSeams(t)
|
||||||
|
listBootedAll = func(context.Context) ([]simDevice, error) { return nil, nil }
|
||||||
|
listAvailable = func(context.Context) ([]simDevice, error) {
|
||||||
|
return []simDevice{{UDID: "shutdown-udid", Name: "iPhone 16", IsAvailable: true}}, nil
|
||||||
|
}
|
||||||
|
udid, isSimulator, err := ResolveTarget(context.Background(), "shutdown-udid")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if udid != "shutdown-udid" || !isSimulator {
|
||||||
|
t.Errorf("got (%q, %v), want (shutdown-udid, true)", udid, isSimulator)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveTarget_UnknownQueryResolvesAsPhysicalDevice(t *testing.T) {
|
||||||
|
swapResolveSeams(t)
|
||||||
|
listBootedAll = func(context.Context) ([]simDevice, error) { return nil, nil }
|
||||||
|
listAvailable = func(context.Context) ([]simDevice, error) {
|
||||||
|
return []simDevice{{UDID: "sim-udid", Name: "iPhone 16", IsAvailable: true}}, nil
|
||||||
|
}
|
||||||
|
udid, isSimulator, err := ResolveTarget(context.Background(), "00008110-physical-device-udid")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if udid != "00008110-physical-device-udid" || isSimulator {
|
||||||
|
t.Errorf("got (%q, %v), want (00008110-physical-device-udid, false)", udid, isSimulator)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveTarget_NoQuerySingleBooted(t *testing.T) {
|
||||||
|
swapResolveSeams(t)
|
||||||
|
listBootedAll = func(context.Context) ([]simDevice, error) {
|
||||||
|
return []simDevice{{UDID: "only-booted", Name: "iPhone 15", State: "Booted", IsAvailable: true}}, nil
|
||||||
|
}
|
||||||
|
udid, isSimulator, err := ResolveTarget(context.Background(), "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if udid != "only-booted" || !isSimulator {
|
||||||
|
t.Errorf("got (%q, %v), want (only-booted, true)", udid, isSimulator)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveTarget_NoQueryZeroBootedErrors(t *testing.T) {
|
||||||
|
swapResolveSeams(t)
|
||||||
|
listBootedAll = func(context.Context) ([]simDevice, error) { return nil, nil }
|
||||||
|
_, _, err := ResolveTarget(context.Background(), "")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when no simulator is booted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveTarget_NoQueryMultipleBootedErrors(t *testing.T) {
|
||||||
|
swapResolveSeams(t)
|
||||||
|
listBootedAll = func(context.Context) ([]simDevice, error) {
|
||||||
|
return []simDevice{
|
||||||
|
{UDID: "udid-a", Name: "iPhone 15", State: "Booted", IsAvailable: true},
|
||||||
|
{UDID: "udid-b", Name: "iPad Pro", State: "Booted", IsAvailable: true},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
_, _, err := ResolveTarget(context.Background(), "")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected ambiguity error for multiple booted simulators")
|
||||||
|
}
|
||||||
|
message := err.Error()
|
||||||
|
for _, want := range []string{"--ios-device", "iPhone 15", "udid-a", "iPad Pro", "udid-b"} {
|
||||||
|
if !strings.Contains(message, want) {
|
||||||
|
t.Errorf("ambiguity error missing %q: %v", want, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveTarget_BootedListError(t *testing.T) {
|
||||||
|
swapResolveSeams(t)
|
||||||
|
listBootedAll = func(context.Context) ([]simDevice, error) { return nil, errors.New("xcrun blew up") }
|
||||||
|
if _, _, err := ResolveTarget(context.Background(), ""); err == nil {
|
||||||
|
t.Fatal("expected booted-list error to propagate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseBootedDevices_CollectsAllBooted(t *testing.T) {
|
||||||
|
got, err := parseBootedDevices([]byte(`{"devices":{"r":[
|
||||||
|
{"udid":"a","state":"Booted","name":"iPhone 15","isAvailable":true},
|
||||||
|
{"udid":"b","state":"Shutdown","name":"iPad","isAvailable":true},
|
||||||
|
{"udid":"c","state":"Booted","name":"iPhone 16","isAvailable":true}
|
||||||
|
]}}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("got %d booted, want 2", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestEnsureSimulator_BootedListError(t *testing.T) {
|
func TestEnsureSimulator_BootedListError(t *testing.T) {
|
||||||
origListBooted := listBooted
|
origListBooted := listBooted
|
||||||
t.Cleanup(func() { listBooted = origListBooted })
|
t.Cleanup(func() { listBooted = origListBooted })
|
||||||
|
|||||||
@@ -67,9 +67,9 @@ type violationAttribution struct {
|
|||||||
|
|
||||||
func decodeStepSummary(line []byte) (StepSummary, []violationAttribution, error) {
|
func decodeStepSummary(line []byte) (StepSummary, []violationAttribution, error) {
|
||||||
var partial struct {
|
var partial struct {
|
||||||
Index int `json:"step"`
|
Index int `json:"step"`
|
||||||
Timestamp time.Time `json:"timestamp"`
|
Timestamp time.Time `json:"timestamp"`
|
||||||
Screen string `json:"screen,omitempty"`
|
Screen string `json:"screen,omitempty"`
|
||||||
NextAction *struct {
|
NextAction *struct {
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
X int `json:"x,omitempty"`
|
X int `json:"x,omitempty"`
|
||||||
|
|||||||
@@ -284,4 +284,3 @@ func ResolveRunsDirectory(argument string) (string, string, error) {
|
|||||||
}
|
}
|
||||||
return argument, "", nil
|
return argument, "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -935,4 +935,3 @@ const maxConsecutiveApplyFailures = 3
|
|||||||
func isWDADrop(err error) bool {
|
func isWDADrop(err error) bool {
|
||||||
return strings.Contains(err.Error(), "WDA reconnect failed")
|
return strings.Contains(err.Error(), "WDA reconnect failed")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14,14 +14,15 @@ import (
|
|||||||
"github.com/priyanshujain/sanderling/internal/android"
|
"github.com/priyanshujain/sanderling/internal/android"
|
||||||
"github.com/priyanshujain/sanderling/internal/driver"
|
"github.com/priyanshujain/sanderling/internal/driver"
|
||||||
"github.com/priyanshujain/sanderling/internal/driver/chrome"
|
"github.com/priyanshujain/sanderling/internal/driver/chrome"
|
||||||
|
"github.com/priyanshujain/sanderling/internal/driver/ioscompanion"
|
||||||
driverSidecar "github.com/priyanshujain/sanderling/internal/driver/sidecar"
|
driverSidecar "github.com/priyanshujain/sanderling/internal/driver/sidecar"
|
||||||
"github.com/priyanshujain/sanderling/internal/ios"
|
|
||||||
"github.com/priyanshujain/sanderling/internal/sidecarassets"
|
"github.com/priyanshujain/sanderling/internal/sidecarassets"
|
||||||
)
|
)
|
||||||
|
|
||||||
// buildDriver creates the appropriate DeviceDriver for the platform and returns
|
// buildDriver creates the appropriate DeviceDriver for the platform and returns
|
||||||
// a cleanup function. For web, ChromeDriver is used directly; for android/ios
|
// a cleanup function. For web, ChromeDriver is used directly. An iOS simulator
|
||||||
// the JVM sidecar is extracted, spawned, and dialed.
|
// is driven by the native simulator companion (no JVM). Android and physical
|
||||||
|
// iOS devices use the JVM sidecar, which is extracted, spawned, and dialed.
|
||||||
func buildDriver(ctx context.Context, options Options, stdout io.Writer) (driver.DeviceDriver, func(), error) {
|
func buildDriver(ctx context.Context, options Options, stdout io.Writer) (driver.DeviceDriver, func(), error) {
|
||||||
if err := Preflight(ctx, options.Platform); err != nil {
|
if err := Preflight(ctx, options.Platform); err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
@@ -31,6 +32,24 @@ func buildDriver(ctx context.Context, options Options, stdout io.Writer) (driver
|
|||||||
return d, func() { _ = d.Terminate(context.Background()) }, nil
|
return d, func() { _ = d.Terminate(context.Background()) }, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if options.Platform == "ios" && options.iosIsSimulator {
|
||||||
|
d, err := ioscompanion.New(ctx, ioscompanion.Options{
|
||||||
|
UniqueDeviceIdentifier: options.iosUDID,
|
||||||
|
BundleID: options.BundleID,
|
||||||
|
AppPath: options.IosAppPath,
|
||||||
|
Output: stdout,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("ios simulator driver: %w", err)
|
||||||
|
}
|
||||||
|
return d, d.Close, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Physical iOS devices and Android use the JVM sidecar, which requires java.
|
||||||
|
if err := preflightDevice(options.Platform); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
sidecarDirectory := os.TempDir() + "/sanderling-sidecar"
|
sidecarDirectory := os.TempDir() + "/sanderling-sidecar"
|
||||||
jarPath, err := sidecarassets.Extract(sidecarDirectory)
|
jarPath, err := sidecarassets.Extract(sidecarDirectory)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -47,8 +66,8 @@ func buildDriver(ctx context.Context, options Options, stdout io.Writer) (driver
|
|||||||
"--platform", options.Platform,
|
"--platform", options.Platform,
|
||||||
}
|
}
|
||||||
if options.Platform == "ios" {
|
if options.Platform == "ios" {
|
||||||
if udid := ios.BootedUDID(ctx); udid != "" {
|
if options.iosUDID != "" {
|
||||||
sidecarArgs = append(sidecarArgs, "--udid", udid)
|
sidecarArgs = append(sidecarArgs, "--udid", options.iosUDID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sidecarCommand := exec.CommandContext(ctx, "java", sidecarArgs...)
|
sidecarCommand := exec.CommandContext(ctx, "java", sidecarArgs...)
|
||||||
|
|||||||
@@ -41,18 +41,36 @@ func runPreflight(ctx context.Context, platform string, check preflightFunc) err
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
case "ios":
|
case "ios":
|
||||||
|
// Simulator runs drive the native companion and need no JVM. The java
|
||||||
|
// requirement is deferred to the physical-device path in buildDriver.
|
||||||
if err := check("xcrun"); err != nil {
|
if err := check("xcrun"); err != nil {
|
||||||
return preflightFailure("ios", err)
|
return preflightFailure("ios", err)
|
||||||
}
|
}
|
||||||
if err := check("java"); err != nil {
|
|
||||||
return preflightFailure("ios", err)
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("preflight: unknown platform %q", platform)
|
return fmt.Errorf("preflight: unknown platform %q", platform)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// preflightDevice runs the extra host checks the JVM sidecar path needs once we
|
||||||
|
// know a run targets a physical iOS device. Android already requires java in
|
||||||
|
// the top-level Preflight, so this only matters for ios.
|
||||||
|
func preflightDevice(platform string) error {
|
||||||
|
return runPreflightDevice(platform, preflightCheck)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runPreflightDevice(platform string, check preflightFunc) error {
|
||||||
|
if platform != "ios" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := check("java"); err != nil {
|
||||||
|
// The doctor splits simulator and device checks: java lives under the
|
||||||
|
// ios-device platform, so the hint must point there.
|
||||||
|
return preflightFailure("ios-device", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func preflightFailure(platform string, cause error) error {
|
func preflightFailure(platform string, cause error) error {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"preflight: %w\nrun `sanderling doctor --platform=%s` for full host-readiness checks",
|
"preflight: %w\nrun `sanderling doctor --platform=%s` for full host-readiness checks",
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ func TestPreflight_AndroidNeedsAdbAndJava(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPreflight_iOSNeedsXcrunAndJava(t *testing.T) {
|
func TestPreflight_iOSNeedsXcrun(t *testing.T) {
|
||||||
check := func(name string) error {
|
check := func(name string) error {
|
||||||
if name == "xcrun" {
|
if name == "xcrun" {
|
||||||
return errors.New("xcrun not found")
|
return errors.New("xcrun not found")
|
||||||
@@ -65,6 +65,25 @@ func TestPreflight_iOSNeedsXcrunAndJava(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPreflight_iOSDoesNotRequireJava(t *testing.T) {
|
||||||
|
checked := []string{}
|
||||||
|
check := func(name string) error {
|
||||||
|
checked = append(checked, name)
|
||||||
|
if name == "java" {
|
||||||
|
return errors.New("java not found")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := runPreflight(context.Background(), "ios", check); err != nil {
|
||||||
|
t.Fatalf("ios preflight should pass without java, got %v", err)
|
||||||
|
}
|
||||||
|
for _, name := range checked {
|
||||||
|
if name == "java" {
|
||||||
|
t.Errorf("ios preflight must not check java; simulator runs need no JVM")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPreflight_AllOK(t *testing.T) {
|
func TestPreflight_AllOK(t *testing.T) {
|
||||||
check := func(name string) error { return nil }
|
check := func(name string) error { return nil }
|
||||||
for _, platform := range []string{"web", "android", "ios"} {
|
for _, platform := range []string{"web", "android", "ios"} {
|
||||||
@@ -74,6 +93,30 @@ func TestPreflight_AllOK(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPreflightDevice_NonIosIsNoop(t *testing.T) {
|
||||||
|
for _, platform := range []string{"web", "android"} {
|
||||||
|
if err := preflightDevice(platform); err != nil {
|
||||||
|
t.Errorf("%s: preflightDevice should be a no-op, got %v", platform, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreflightDevice_JavaFailurePointsAtIosDeviceDoctor(t *testing.T) {
|
||||||
|
check := func(name string) error {
|
||||||
|
if name == "java" {
|
||||||
|
return errors.New("java not found")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
err := runPreflightDevice("ios", check)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "java") {
|
||||||
|
t.Fatalf("expected java error, got %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "sanderling doctor --platform=ios-device") {
|
||||||
|
t.Errorf("hint must name the ios-device doctor platform: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPreflight_UnknownPlatform(t *testing.T) {
|
func TestPreflight_UnknownPlatform(t *testing.T) {
|
||||||
check := func(string) error { return nil }
|
check := func(string) error { return nil }
|
||||||
if err := runPreflight(context.Background(), "fuchsia", check); err == nil {
|
if err := runPreflight(context.Background(), "fuchsia", check); err == nil {
|
||||||
|
|||||||
+38
-11
@@ -23,15 +23,21 @@ const sidecarStartupTimeout = 30 * time.Second
|
|||||||
|
|
||||||
// Options are the parameters for a single test pipeline run.
|
// Options are the parameters for a single test pipeline run.
|
||||||
type Options struct {
|
type Options struct {
|
||||||
Spec string
|
Spec string
|
||||||
BundleID string
|
BundleID string
|
||||||
Platform string
|
Platform string
|
||||||
AVD string
|
AVD string
|
||||||
IosDevice string
|
IosDevice string
|
||||||
Duration time.Duration
|
IosAppPath string
|
||||||
Seed int64
|
Duration time.Duration
|
||||||
Output string
|
Seed int64
|
||||||
ClearData bool
|
Output string
|
||||||
|
ClearData bool
|
||||||
|
|
||||||
|
// iosUDID and iosIsSimulator are filled by Execute after resolving the iOS
|
||||||
|
// target, then read by buildDriver to choose the companion or sidecar path.
|
||||||
|
iosUDID string
|
||||||
|
iosIsSimulator bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute runs the full test pipeline: bundle, launch app, verify properties.
|
// Execute runs the full test pipeline: bundle, launch app, verify properties.
|
||||||
@@ -42,9 +48,30 @@ func Execute(ctx context.Context, options Options, stdout io.Writer) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
case "ios":
|
case "ios":
|
||||||
if err := ios.EnsureSimulator(ctx, options.IosDevice, stdout); err != nil {
|
udid, isSimulator, err := ios.ResolveTarget(ctx, options.IosDevice)
|
||||||
return err
|
if err != nil {
|
||||||
|
// No query and nothing booted: keep boot-first behavior, then resolve
|
||||||
|
// the simulator that EnsureSimulator just brought up.
|
||||||
|
if options.IosDevice == "" {
|
||||||
|
if bootErr := ios.EnsureSimulator(ctx, "", stdout); bootErr != nil {
|
||||||
|
return bootErr
|
||||||
|
}
|
||||||
|
udid, isSimulator, err = ios.ResolveTarget(ctx, "")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else if isSimulator {
|
||||||
|
if err := ios.EnsureSimulator(ctx, options.IosDevice, stdout); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
udid, isSimulator, err = ios.ResolveTarget(ctx, options.IosDevice)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
options.iosUDID = udid
|
||||||
|
options.iosIsSimulator = isSimulator
|
||||||
}
|
}
|
||||||
prep, err := prepareBundleInputs(options)
|
prep, err := prepareBundleInputs(options)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -22,13 +22,13 @@ func TestWriteMeta_RoundTrip(t *testing.T) {
|
|||||||
defer writer.Close()
|
defer writer.Close()
|
||||||
|
|
||||||
meta := Meta{
|
meta := Meta{
|
||||||
Seed: 42,
|
Seed: 42,
|
||||||
SpecPath: "spec.ts",
|
SpecPath: "spec.ts",
|
||||||
BundleSHA256: "deadbeef",
|
BundleSHA256: "deadbeef",
|
||||||
Platform: "android",
|
Platform: "android",
|
||||||
BundleID: "in.okcredit.merchant",
|
BundleID: "in.okcredit.merchant",
|
||||||
StartedAt: time.Date(2026, 4, 17, 22, 30, 0, 0, time.UTC),
|
StartedAt: time.Date(2026, 4, 17, 22, 30, 0, 0, time.UTC),
|
||||||
SanderlingVersion: "0.0.1",
|
SanderlingVersion: "0.0.1",
|
||||||
}
|
}
|
||||||
if err := writer.WriteMeta(meta); err != nil {
|
if err := writer.WriteMeta(meta); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -57,12 +57,12 @@ func TestWriteMeta_EndedAtRoundTrip(t *testing.T) {
|
|||||||
|
|
||||||
endedAt := time.Date(2026, 4, 17, 22, 31, 0, 0, time.UTC)
|
endedAt := time.Date(2026, 4, 17, 22, 31, 0, 0, time.UTC)
|
||||||
meta := Meta{
|
meta := Meta{
|
||||||
Seed: 7,
|
Seed: 7,
|
||||||
SpecPath: "spec.ts",
|
SpecPath: "spec.ts",
|
||||||
Platform: "android",
|
Platform: "android",
|
||||||
BundleID: "in.test",
|
BundleID: "in.test",
|
||||||
StartedAt: time.Date(2026, 4, 17, 22, 30, 0, 0, time.UTC),
|
StartedAt: time.Date(2026, 4, 17, 22, 30, 0, 0, time.UTC),
|
||||||
EndedAt: &endedAt,
|
EndedAt: &endedAt,
|
||||||
SanderlingVersion: "0.0.1",
|
SanderlingVersion: "0.0.1",
|
||||||
}
|
}
|
||||||
if err := writer.WriteMeta(meta); err != nil {
|
if err := writer.WriteMeta(meta); err != nil {
|
||||||
|
|||||||
@@ -47,17 +47,17 @@ func bundleIntegrationSpec(t *testing.T) string {
|
|||||||
|
|
||||||
func listSnapshots() Snapshots {
|
func listSnapshots() Snapshots {
|
||||||
return Snapshots{
|
return Snapshots{
|
||||||
"route": json.RawMessage(`"list"`),
|
"route": json.RawMessage(`"list"`),
|
||||||
"item_count": json.RawMessage(`0`),
|
"item_count": json.RawMessage(`0`),
|
||||||
"has_submitted": json.RawMessage(`false`),
|
"has_submitted": json.RawMessage(`false`),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func formSnapshots() Snapshots {
|
func formSnapshots() Snapshots {
|
||||||
return Snapshots{
|
return Snapshots{
|
||||||
"route": json.RawMessage(`"form"`),
|
"route": json.RawMessage(`"form"`),
|
||||||
"item_count": json.RawMessage(`0`),
|
"item_count": json.RawMessage(`0`),
|
||||||
"has_submitted": json.RawMessage(`false`),
|
"has_submitted": json.RawMessage(`false`),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Dedicated generation template for the pinned simulator companion proto.
|
||||||
|
# This is intentionally separate from the root buf.gen.yaml so that the
|
||||||
|
# generated stubs land in the Go internal package rather than under proto/,
|
||||||
|
# and so a normal `make proto` never touches these committed stubs.
|
||||||
|
#
|
||||||
|
# Reproduce the committed stubs from the repository root with:
|
||||||
|
# buf generate proto/idbcompanion --template proto/idbcompanion/buf.gen.yaml
|
||||||
|
# The module= options strip the Go module prefix so the files land at
|
||||||
|
# internal/driver/ioscompanion/companionpb relative to the repository root.
|
||||||
|
version: v2
|
||||||
|
plugins:
|
||||||
|
- local: protoc-gen-go
|
||||||
|
out: .
|
||||||
|
opt:
|
||||||
|
- module=github.com/priyanshujain/sanderling
|
||||||
|
- local: protoc-gen-go-grpc
|
||||||
|
out: .
|
||||||
|
opt:
|
||||||
|
- module=github.com/priyanshujain/sanderling
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Standalone buf module for the pinned simulator companion proto. It is kept
|
||||||
|
# out of the root workspace so the root `buf lint` and `buf generate` never see
|
||||||
|
# this file. Lint here is relaxed because the proto is copied verbatim from
|
||||||
|
# upstream and must stay byte-identical apart from the go_package option.
|
||||||
|
version: v2
|
||||||
|
modules:
|
||||||
|
- path: .
|
||||||
|
lint:
|
||||||
|
use:
|
||||||
|
- MINIMAL
|
||||||
|
except:
|
||||||
|
- PACKAGE_DIRECTORY_MATCH
|
||||||
|
breaking:
|
||||||
|
use:
|
||||||
|
- FILE
|
||||||
@@ -0,0 +1,930 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||||
|
*
|
||||||
|
* This source code is licensed under the MIT license found in the
|
||||||
|
* LICENSE file in the root directory of this source tree.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Pinned to the simulator companion v1.1.8 and matches the embedded binary.
|
||||||
|
// Do NOT regenerate this file from upstream HEAD: HEAD has drifted from the
|
||||||
|
// pinned binary, so regenerating would produce stubs that no longer match the
|
||||||
|
// companion this driver speaks to. The only local change versus the upstream
|
||||||
|
// source is the go_package option below, which targets the committed stub
|
||||||
|
// package internal/driver/ioscompanion/companionpb. Regenerate the stubs from
|
||||||
|
// the repository root with:
|
||||||
|
// buf generate proto/idbcompanion --template proto/idbcompanion/buf.gen.yaml
|
||||||
|
|
||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package idb;
|
||||||
|
|
||||||
|
option go_package = "github.com/priyanshujain/sanderling/internal/driver/ioscompanion/companionpb;companionpb";
|
||||||
|
|
||||||
|
// The idb companion service definition.
|
||||||
|
service CompanionService {
|
||||||
|
// Management
|
||||||
|
rpc connect(ConnectRequest) returns (ConnectResponse) {}
|
||||||
|
rpc debugserver(stream DebugServerRequest)
|
||||||
|
returns (stream DebugServerResponse) {}
|
||||||
|
rpc dap(stream DapRequest) returns (stream DapResponse) {}
|
||||||
|
rpc describe(TargetDescriptionRequest) returns (TargetDescriptionResponse) {}
|
||||||
|
rpc install(stream InstallRequest) returns (stream InstallResponse) {}
|
||||||
|
rpc instruments_run(stream InstrumentsRunRequest)
|
||||||
|
returns (stream InstrumentsRunResponse) {}
|
||||||
|
rpc log(LogRequest) returns (stream LogResponse) {}
|
||||||
|
rpc xctrace_record(stream XctraceRecordRequest)
|
||||||
|
returns (stream XctraceRecordResponse) {}
|
||||||
|
// Interaction
|
||||||
|
rpc accessibility_info(AccessibilityInfoRequest)
|
||||||
|
returns (AccessibilityInfoResponse) {}
|
||||||
|
rpc focus(FocusRequest) returns (FocusResponse) {}
|
||||||
|
rpc hid(stream HIDEvent) returns (HIDResponse) {}
|
||||||
|
rpc open_url(OpenUrlRequest) returns (OpenUrlRequest) {}
|
||||||
|
rpc set_location(SetLocationRequest) returns (SetLocationResponse) {}
|
||||||
|
rpc send_notification(SendNotificationRequest)
|
||||||
|
returns (SendNotificationResponse) {}
|
||||||
|
rpc simulate_memory_warning(SimulateMemoryWarningRequest)
|
||||||
|
returns (SimulateMemoryWarningResponse) {}
|
||||||
|
// Settings
|
||||||
|
rpc approve(ApproveRequest) returns (ApproveResponse) {}
|
||||||
|
rpc revoke(RevokeRequest) returns (RevokeResponse) {}
|
||||||
|
rpc clear_keychain(ClearKeychainRequest) returns (ClearKeychainResponse) {}
|
||||||
|
rpc contacts_update(ContactsUpdateRequest) returns (ContactsUpdateResponse) {}
|
||||||
|
rpc setting(SettingRequest) returns (SettingResponse) {} // Sets settings
|
||||||
|
rpc get_setting(GetSettingRequest) returns (GetSettingResponse) {
|
||||||
|
} // Gets current setting
|
||||||
|
rpc list_settings(ListSettingRequest) returns (ListSettingResponse) {
|
||||||
|
} // Lists available settings
|
||||||
|
// App
|
||||||
|
rpc launch(stream LaunchRequest) returns (stream LaunchResponse) {}
|
||||||
|
rpc list_apps(ListAppsRequest) returns (ListAppsResponse) {}
|
||||||
|
rpc terminate(TerminateRequest) returns (TerminateResponse) {}
|
||||||
|
rpc uninstall(UninstallRequest) returns (UninstallResponse) {}
|
||||||
|
// Video/Audio
|
||||||
|
rpc add_media(stream AddMediaRequest) returns (AddMediaResponse) {}
|
||||||
|
rpc record(stream RecordRequest) returns (stream RecordResponse) {}
|
||||||
|
rpc screenshot(ScreenshotRequest) returns (ScreenshotResponse) {}
|
||||||
|
rpc video_stream(stream VideoStreamRequest)
|
||||||
|
returns (stream VideoStreamResponse) {}
|
||||||
|
// Crash Operations
|
||||||
|
rpc crash_delete(CrashLogQuery) returns (CrashLogResponse) {}
|
||||||
|
rpc crash_list(CrashLogQuery) returns (CrashLogResponse) {}
|
||||||
|
rpc crash_show(CrashShowRequest) returns (CrashShowResponse) {}
|
||||||
|
// xctest operations
|
||||||
|
rpc xctest_list_bundles(XctestListBundlesRequest)
|
||||||
|
returns (XctestListBundlesResponse) {}
|
||||||
|
rpc xctest_list_tests(XctestListTestsRequest)
|
||||||
|
returns (XctestListTestsResponse) {}
|
||||||
|
rpc xctest_run(XctestRunRequest) returns (stream XctestRunResponse) {}
|
||||||
|
// File Operations
|
||||||
|
rpc ls(LsRequest) returns (LsResponse) {}
|
||||||
|
rpc mkdir(MkdirRequest) returns (MkdirResponse) {}
|
||||||
|
rpc mv(MvRequest) returns (MvResponse) {}
|
||||||
|
rpc rm(RmRequest) returns (RmResponse) {}
|
||||||
|
rpc pull(PullRequest) returns (stream PullResponse) {}
|
||||||
|
rpc push(stream PushRequest) returns (PushResponse) {}
|
||||||
|
rpc tail(stream TailRequest) returns (stream TailResponse) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
message Payload {
|
||||||
|
oneof source {
|
||||||
|
string file_path = 1;
|
||||||
|
bytes data = 2;
|
||||||
|
string url = 3;
|
||||||
|
Compression compression = 4;
|
||||||
|
}
|
||||||
|
enum Compression {
|
||||||
|
GZIP = 0;
|
||||||
|
ZSTD = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message ProcessOutput {
|
||||||
|
enum Interface {
|
||||||
|
STDOUT = 0;
|
||||||
|
STDERR = 1;
|
||||||
|
}
|
||||||
|
Interface interface = 1;
|
||||||
|
bytes data = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CompanionInfo {
|
||||||
|
string udid = 1;
|
||||||
|
bool is_local = 4;
|
||||||
|
bytes metadata = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Setting {
|
||||||
|
LOCALE = 0;
|
||||||
|
ANY = 1; // any kind of app or global setting
|
||||||
|
}
|
||||||
|
|
||||||
|
message SettingRequest {
|
||||||
|
message HardwareKeyboard {
|
||||||
|
bool enabled = 1;
|
||||||
|
}
|
||||||
|
message StringSetting {
|
||||||
|
Setting setting = 1;
|
||||||
|
string value = 2;
|
||||||
|
string name = 3; // iff setting == ANY
|
||||||
|
string domain = 4; // assumed "Apple Global Domain" if empty
|
||||||
|
/*
|
||||||
|
* Currently supported value_types and corresponding values are:
|
||||||
|
* string <string_value>
|
||||||
|
* data <hex_digits>
|
||||||
|
* int[eger] <integer_value>
|
||||||
|
* float <floating-point_value>
|
||||||
|
* bool[ean] (true | false | yes | no)
|
||||||
|
* date <date_rep>
|
||||||
|
* array <value1> <value2> ...
|
||||||
|
* array-add <value1> <value2> ...
|
||||||
|
* dict <key1> <value1> <key2> <value2> ...
|
||||||
|
* dict-add <key1> <value1> ...
|
||||||
|
*
|
||||||
|
* Check defaults set help for more details.
|
||||||
|
*/
|
||||||
|
string value_type = 5;
|
||||||
|
}
|
||||||
|
oneof setting {
|
||||||
|
HardwareKeyboard hardwareKeyboard = 1;
|
||||||
|
StringSetting stringSetting = 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message SettingResponse {}
|
||||||
|
|
||||||
|
message GetSettingRequest {
|
||||||
|
Setting setting = 1;
|
||||||
|
string name = 2; // iff setting == ANY
|
||||||
|
string domain = 3; // assumed "Apple Global Domain" if empty
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetSettingResponse {
|
||||||
|
string value = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListSettingRequest {
|
||||||
|
Setting setting = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListSettingResponse {
|
||||||
|
repeated string values = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListAppsRequest {
|
||||||
|
bool suppress_process_state = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListAppsResponse {
|
||||||
|
repeated InstalledAppInfo apps = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message InstalledAppInfo {
|
||||||
|
string bundle_id = 1;
|
||||||
|
string name = 2;
|
||||||
|
repeated string architectures = 3;
|
||||||
|
string install_type = 4;
|
||||||
|
enum AppProcessState {
|
||||||
|
UNKNOWN = 0;
|
||||||
|
NOT_RUNNING = 1;
|
||||||
|
RUNNING = 2;
|
||||||
|
}
|
||||||
|
AppProcessState process_state = 5;
|
||||||
|
bool debuggable = 6;
|
||||||
|
uint64 process_identifier = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
message InstallRequest {
|
||||||
|
enum Destination {
|
||||||
|
APP = 0;
|
||||||
|
XCTEST = 1;
|
||||||
|
DYLIB = 2;
|
||||||
|
DSYM = 3;
|
||||||
|
FRAMEWORK = 4;
|
||||||
|
}
|
||||||
|
message LinkDsymToBundle {
|
||||||
|
enum BundleType {
|
||||||
|
APP = 0;
|
||||||
|
XCTEST = 1;
|
||||||
|
}
|
||||||
|
BundleType bundle_type = 1;
|
||||||
|
string bundle_id = 2;
|
||||||
|
}
|
||||||
|
oneof value {
|
||||||
|
Destination destination = 1;
|
||||||
|
Payload payload = 2;
|
||||||
|
string name_hint = 3;
|
||||||
|
bool make_debuggable = 4;
|
||||||
|
string bundle_id = 5; //(2022-03-02) REMOVE, For back compatibility only.
|
||||||
|
// Link dSYM to app bundle_id
|
||||||
|
LinkDsymToBundle link_dsym_to_bundle = 6;
|
||||||
|
bool override_modification_time = 7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message InstallResponse {
|
||||||
|
string name = 1;
|
||||||
|
string uuid = 2;
|
||||||
|
double progress = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ScreenshotRequest {}
|
||||||
|
|
||||||
|
message ScreenshotResponse {
|
||||||
|
bytes image_data = 1;
|
||||||
|
string image_format = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message FocusRequest {}
|
||||||
|
|
||||||
|
message FocusResponse {}
|
||||||
|
|
||||||
|
message Point {
|
||||||
|
double x = 1;
|
||||||
|
double y = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message AccessibilityInfoRequest {
|
||||||
|
enum Format {
|
||||||
|
LEGACY = 0;
|
||||||
|
NESTED = 1;
|
||||||
|
}
|
||||||
|
Point point = 2;
|
||||||
|
Format format = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message AccessibilityInfoResponse {
|
||||||
|
string json = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ApproveRequest {
|
||||||
|
enum Permission {
|
||||||
|
PHOTOS = 0;
|
||||||
|
CAMERA = 1;
|
||||||
|
CONTACTS = 2;
|
||||||
|
URL = 3;
|
||||||
|
LOCATION = 4;
|
||||||
|
NOTIFICATION = 5;
|
||||||
|
MICROPHONE = 6;
|
||||||
|
}
|
||||||
|
string bundle_id = 1;
|
||||||
|
repeated Permission permissions = 2;
|
||||||
|
string scheme = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ApproveResponse {}
|
||||||
|
|
||||||
|
message RevokeRequest {
|
||||||
|
enum Permission {
|
||||||
|
PHOTOS = 0;
|
||||||
|
CAMERA = 1;
|
||||||
|
CONTACTS = 2;
|
||||||
|
URL = 3;
|
||||||
|
LOCATION = 4;
|
||||||
|
NOTIFICATION = 5;
|
||||||
|
MICROPHONE = 6;
|
||||||
|
}
|
||||||
|
string bundle_id = 1;
|
||||||
|
repeated Permission permissions = 2;
|
||||||
|
string scheme = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message RevokeResponse {}
|
||||||
|
|
||||||
|
message ClearKeychainRequest {}
|
||||||
|
|
||||||
|
message ClearKeychainResponse {}
|
||||||
|
|
||||||
|
message SetLocationRequest {
|
||||||
|
Location location = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Location {
|
||||||
|
double latitude = 1;
|
||||||
|
double longitude = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SetLocationResponse {}
|
||||||
|
|
||||||
|
message UninstallRequest {
|
||||||
|
string bundle_id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message UninstallResponse {}
|
||||||
|
|
||||||
|
message TerminateRequest {
|
||||||
|
string bundle_id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TerminateResponse {}
|
||||||
|
|
||||||
|
message OpenUrlRequest {
|
||||||
|
string url = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message OpenUrlResponse {}
|
||||||
|
|
||||||
|
message ContactsUpdateRequest {
|
||||||
|
Payload payload = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ContactsUpdateResponse {}
|
||||||
|
|
||||||
|
message TargetDescriptionRequest {
|
||||||
|
bool fetch_diagnostics = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TargetDescriptionResponse {
|
||||||
|
TargetDescription target_description = 1;
|
||||||
|
CompanionInfo companion = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message HIDEvent {
|
||||||
|
enum HIDDirection {
|
||||||
|
DOWN = 0;
|
||||||
|
UP = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message HIDTouch {
|
||||||
|
Point point = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum HIDButtonType {
|
||||||
|
APPLE_PAY = 0;
|
||||||
|
HOME = 1;
|
||||||
|
LOCK = 2;
|
||||||
|
SIDE_BUTTON = 3;
|
||||||
|
SIRI = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message HIDButton {
|
||||||
|
HIDButtonType button = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message HIDKey {
|
||||||
|
uint64 keycode = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message HIDPressAction {
|
||||||
|
oneof action {
|
||||||
|
HIDTouch touch = 1;
|
||||||
|
HIDButton button = 2;
|
||||||
|
HIDKey key = 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message HIDPress {
|
||||||
|
HIDPressAction action = 1;
|
||||||
|
HIDDirection direction = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message HIDSwipe {
|
||||||
|
Point start = 1;
|
||||||
|
Point end = 2;
|
||||||
|
double delta = 5;
|
||||||
|
double duration = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
message HIDDelay {
|
||||||
|
double duration = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
oneof event {
|
||||||
|
HIDPress press = 1;
|
||||||
|
HIDSwipe swipe = 2;
|
||||||
|
HIDDelay delay = 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message HIDResponse {}
|
||||||
|
|
||||||
|
message ConnectRequest {
|
||||||
|
map<string, string> metadata = 1;
|
||||||
|
string local_file_path = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ConnectResponse {
|
||||||
|
CompanionInfo companion = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ScreenDimensions {
|
||||||
|
uint64 width = 1;
|
||||||
|
uint64 height = 2;
|
||||||
|
double density = 3;
|
||||||
|
uint64 width_points = 4;
|
||||||
|
uint64 height_points = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TargetDescription {
|
||||||
|
string udid = 1;
|
||||||
|
string name = 2;
|
||||||
|
ScreenDimensions screen_dimensions = 3;
|
||||||
|
string state = 4;
|
||||||
|
string target_type = 5;
|
||||||
|
string os_version = 6;
|
||||||
|
string architecture = 7;
|
||||||
|
bytes extended = 9;
|
||||||
|
bytes diagnostics = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
message LogRequest {
|
||||||
|
enum Source {
|
||||||
|
TARGET = 0;
|
||||||
|
COMPANION = 1;
|
||||||
|
}
|
||||||
|
repeated string arguments = 1;
|
||||||
|
Source source = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message LogResponse {
|
||||||
|
bytes output = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message RecordRequest {
|
||||||
|
message Start {
|
||||||
|
string file_path = 1;
|
||||||
|
}
|
||||||
|
message Stop {}
|
||||||
|
oneof control {
|
||||||
|
Start start = 1;
|
||||||
|
Stop stop = 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message RecordResponse {
|
||||||
|
oneof output {
|
||||||
|
bytes log_output = 1;
|
||||||
|
Payload payload = 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message VideoStreamRequest {
|
||||||
|
enum Format {
|
||||||
|
H264 = 0;
|
||||||
|
RBGA = 1;
|
||||||
|
MJPEG = 2;
|
||||||
|
MINICAP = 3;
|
||||||
|
I420 = 4;
|
||||||
|
}
|
||||||
|
message Start {
|
||||||
|
string file_path = 1;
|
||||||
|
uint64 fps = 2;
|
||||||
|
Format format = 3;
|
||||||
|
double compression_quality = 4;
|
||||||
|
double scale_factor = 5;
|
||||||
|
double avg_bitrate = 6;
|
||||||
|
}
|
||||||
|
message Stop {}
|
||||||
|
oneof control {
|
||||||
|
Start start = 1;
|
||||||
|
Stop stop = 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message VideoStreamResponse {
|
||||||
|
oneof output {
|
||||||
|
bytes log_output = 1;
|
||||||
|
Payload payload = 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message LaunchRequest {
|
||||||
|
message Start {
|
||||||
|
string bundle_id = 1;
|
||||||
|
map<string, string> env = 2;
|
||||||
|
repeated string app_args = 3;
|
||||||
|
bool foreground_if_running = 4;
|
||||||
|
bool wait_for = 5;
|
||||||
|
bool wait_for_debugger = 6;
|
||||||
|
}
|
||||||
|
message Stop {}
|
||||||
|
oneof control {
|
||||||
|
Start start = 1;
|
||||||
|
Stop stop = 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message LaunchResponse {
|
||||||
|
ProcessOutput output = 3;
|
||||||
|
DebuggerInfo debugger = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message AddMediaRequest {
|
||||||
|
Payload payload = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message AddMediaResponse {}
|
||||||
|
|
||||||
|
message InstrumentsRunRequest {
|
||||||
|
message InstrumentsTimings {
|
||||||
|
double terminate_timeout = 1;
|
||||||
|
double launch_retry_timeout = 2;
|
||||||
|
double launch_error_timeout = 3;
|
||||||
|
double operation_duration = 4;
|
||||||
|
}
|
||||||
|
message Start {
|
||||||
|
string template_name = 2;
|
||||||
|
string app_bundle_id = 3;
|
||||||
|
map<string, string> environment = 4;
|
||||||
|
repeated string arguments = 5;
|
||||||
|
InstrumentsTimings timings = 6;
|
||||||
|
repeated string tool_arguments = 7;
|
||||||
|
}
|
||||||
|
message Stop {
|
||||||
|
repeated string post_process_arguments = 1;
|
||||||
|
}
|
||||||
|
oneof control {
|
||||||
|
Start start = 1;
|
||||||
|
Stop stop = 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message InstrumentsRunResponse {
|
||||||
|
enum State {
|
||||||
|
UNKNOWN = 0;
|
||||||
|
RUNNING_INSTRUMENTS = 1;
|
||||||
|
POST_PROCESSING = 2;
|
||||||
|
}
|
||||||
|
oneof output {
|
||||||
|
bytes log_output = 1;
|
||||||
|
Payload payload = 2;
|
||||||
|
State state = 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message XctraceRecordRequest {
|
||||||
|
message LauchProcess {
|
||||||
|
string process_to_launch = 1;
|
||||||
|
repeated string launch_args = 2;
|
||||||
|
string target_stdin = 3;
|
||||||
|
string target_stdout = 4;
|
||||||
|
map<string, string> process_env = 5;
|
||||||
|
}
|
||||||
|
message Target {
|
||||||
|
oneof target {
|
||||||
|
bool all_processes = 1;
|
||||||
|
string process_to_attach = 2;
|
||||||
|
LauchProcess launch_process = 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
message Start {
|
||||||
|
string template_name = 1;
|
||||||
|
double time_limit = 2;
|
||||||
|
string package = 3;
|
||||||
|
Target target = 4;
|
||||||
|
}
|
||||||
|
message Stop {
|
||||||
|
double timeout = 1;
|
||||||
|
repeated string args = 2;
|
||||||
|
}
|
||||||
|
oneof control {
|
||||||
|
Start start = 1;
|
||||||
|
Stop stop = 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message XctraceRecordResponse {
|
||||||
|
enum State {
|
||||||
|
UNKNOWN = 0;
|
||||||
|
RUNNING = 1;
|
||||||
|
PROCESSING = 2;
|
||||||
|
}
|
||||||
|
oneof output {
|
||||||
|
bytes log = 1;
|
||||||
|
Payload payload = 2;
|
||||||
|
State state = 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message DebugServerRequest {
|
||||||
|
message Start {
|
||||||
|
string bundle_id = 1;
|
||||||
|
}
|
||||||
|
message Status {}
|
||||||
|
message Stop {}
|
||||||
|
message Pipe {
|
||||||
|
bytes data = 1;
|
||||||
|
}
|
||||||
|
oneof control {
|
||||||
|
Start start = 1;
|
||||||
|
Stop stop = 2;
|
||||||
|
Status status = 3;
|
||||||
|
Pipe pipe = 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message DebugServerResponse {
|
||||||
|
message Pipe {
|
||||||
|
bytes data = 1;
|
||||||
|
}
|
||||||
|
message Status {
|
||||||
|
repeated string lldb_bootstrap_commands = 1;
|
||||||
|
}
|
||||||
|
oneof control {
|
||||||
|
Status status = 1;
|
||||||
|
Pipe pipe = 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// crashes
|
||||||
|
|
||||||
|
message CrashShowRequest {
|
||||||
|
string name = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CrashLogResponse {
|
||||||
|
repeated CrashLogInfo list = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CrashLogInfo {
|
||||||
|
string name = 1;
|
||||||
|
string bundle_id = 2;
|
||||||
|
string process_name = 3;
|
||||||
|
string parent_process_name = 4;
|
||||||
|
uint64 process_identifier = 5;
|
||||||
|
uint64 parent_process_identifier = 6;
|
||||||
|
uint64 timestamp = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CrashShowResponse {
|
||||||
|
CrashLogInfo info = 1;
|
||||||
|
string contents = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CrashLogQuery {
|
||||||
|
uint64 since = 1;
|
||||||
|
uint64 before = 2;
|
||||||
|
string bundle_id = 3;
|
||||||
|
string name = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// xctest
|
||||||
|
|
||||||
|
message XctestListBundlesRequest {}
|
||||||
|
|
||||||
|
message XctestListBundlesResponse {
|
||||||
|
message Bundles {
|
||||||
|
string name = 1;
|
||||||
|
string bundle_id = 2;
|
||||||
|
repeated string architectures = 3;
|
||||||
|
}
|
||||||
|
repeated Bundles bundles = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message XctestListTestsRequest {
|
||||||
|
string bundle_name = 1;
|
||||||
|
string app_path = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message XctestListTestsResponse {
|
||||||
|
repeated string names = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message XctestRunRequest {
|
||||||
|
message Logic {}
|
||||||
|
message Application {
|
||||||
|
string app_bundle_id = 1;
|
||||||
|
}
|
||||||
|
message UI {
|
||||||
|
string app_bundle_id = 1;
|
||||||
|
string test_host_app_bundle_id = 2;
|
||||||
|
}
|
||||||
|
message Mode {
|
||||||
|
oneof mode {
|
||||||
|
Logic logic = 1;
|
||||||
|
Application application = 2;
|
||||||
|
UI ui = 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
message CodeCoverage {
|
||||||
|
enum Format {
|
||||||
|
EXPORTED = 0;
|
||||||
|
RAW = 1;
|
||||||
|
}
|
||||||
|
bool collect = 1;
|
||||||
|
Format format = 2;
|
||||||
|
}
|
||||||
|
Mode mode = 1;
|
||||||
|
string test_bundle_id = 2;
|
||||||
|
repeated string tests_to_run = 3;
|
||||||
|
repeated string tests_to_skip = 4;
|
||||||
|
repeated string arguments = 5;
|
||||||
|
map<string, string> environment = 6;
|
||||||
|
uint64 timeout = 7;
|
||||||
|
bool report_activities = 8;
|
||||||
|
bool collect_coverage = 9;
|
||||||
|
bool report_attachments = 10;
|
||||||
|
bool collect_logs = 11;
|
||||||
|
bool wait_for_debugger = 12;
|
||||||
|
CodeCoverage code_coverage = 13;
|
||||||
|
bool collect_result_bundle = 14;
|
||||||
|
}
|
||||||
|
|
||||||
|
message XctestRunResponse {
|
||||||
|
message TestRunInfo {
|
||||||
|
message TestRunFailureInfo {
|
||||||
|
string failure_message = 1;
|
||||||
|
string file = 2;
|
||||||
|
uint64 line = 3;
|
||||||
|
}
|
||||||
|
message TestAttachment {
|
||||||
|
bytes payload = 1;
|
||||||
|
double timestamp = 2;
|
||||||
|
string name = 3;
|
||||||
|
string uniform_type_identifier = 4;
|
||||||
|
bytes user_info_json = 5;
|
||||||
|
}
|
||||||
|
message TestActivity {
|
||||||
|
string title = 1;
|
||||||
|
double duration = 2;
|
||||||
|
string uuid = 3;
|
||||||
|
string activity_type = 4;
|
||||||
|
double start = 5;
|
||||||
|
double finish = 6;
|
||||||
|
string name = 7;
|
||||||
|
repeated TestAttachment attachments = 8;
|
||||||
|
repeated TestActivity sub_activities = 9;
|
||||||
|
}
|
||||||
|
enum Status {
|
||||||
|
PASSED = 0;
|
||||||
|
FAILED = 1;
|
||||||
|
CRASHED = 2;
|
||||||
|
}
|
||||||
|
Status status = 1;
|
||||||
|
string bundle_name = 2;
|
||||||
|
string class_name = 3;
|
||||||
|
string method_name = 4;
|
||||||
|
double duration = 5;
|
||||||
|
TestRunFailureInfo failure_info = 6;
|
||||||
|
repeated string logs = 7;
|
||||||
|
repeated TestActivity activityLogs = 8;
|
||||||
|
}
|
||||||
|
enum Status {
|
||||||
|
RUNNING = 0;
|
||||||
|
TERMINATED_NORMALLY = 1;
|
||||||
|
TERMINATED_ABNORMALLY = 2;
|
||||||
|
}
|
||||||
|
Status status = 1;
|
||||||
|
repeated TestRunInfo results = 2;
|
||||||
|
repeated string log_output = 3;
|
||||||
|
Payload result_bundle = 4;
|
||||||
|
string coverage_json = 5;
|
||||||
|
Payload log_directory = 6;
|
||||||
|
DebuggerInfo debugger = 7;
|
||||||
|
Payload code_coverage_data = 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
// File
|
||||||
|
|
||||||
|
message FileContainer {
|
||||||
|
enum Kind {
|
||||||
|
NONE = 0;
|
||||||
|
APPLICATION = 1;
|
||||||
|
ROOT = 2;
|
||||||
|
MEDIA = 3;
|
||||||
|
CRASHES = 4;
|
||||||
|
PROVISIONING_PROFILES = 5;
|
||||||
|
MDM_PROFILES = 6;
|
||||||
|
SPRINGBOARD_ICONS = 7;
|
||||||
|
WALLPAPER = 8;
|
||||||
|
DISK_IMAGES = 9;
|
||||||
|
GROUP_CONTAINER = 10;
|
||||||
|
APPLICATION_CONTAINER = 11;
|
||||||
|
AUXILLARY = 12;
|
||||||
|
XCTEST = 13;
|
||||||
|
DYLIB = 14;
|
||||||
|
DSYM = 15;
|
||||||
|
FRAMEWORK = 16;
|
||||||
|
SYMBOLS = 17;
|
||||||
|
}
|
||||||
|
Kind kind = 1;
|
||||||
|
string bundle_id = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message FileInfo {
|
||||||
|
string path = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message FileListing {
|
||||||
|
FileInfo parent = 1;
|
||||||
|
repeated FileInfo files = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message LsResponse {
|
||||||
|
repeated FileInfo files = 1;
|
||||||
|
repeated FileListing listings = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message LsRequest {
|
||||||
|
string path = 2;
|
||||||
|
FileContainer container = 3;
|
||||||
|
repeated string paths = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message MkdirRequest {
|
||||||
|
string path = 2;
|
||||||
|
FileContainer container = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message MkdirResponse {}
|
||||||
|
|
||||||
|
message MvRequest {
|
||||||
|
repeated string src_paths = 2;
|
||||||
|
string dst_path = 3;
|
||||||
|
FileContainer container = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message MvResponse {}
|
||||||
|
|
||||||
|
message RmRequest {
|
||||||
|
repeated string paths = 2;
|
||||||
|
FileContainer container = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message RmResponse {}
|
||||||
|
|
||||||
|
message PushRequest {
|
||||||
|
message Inner {
|
||||||
|
string dst_path = 2;
|
||||||
|
FileContainer container = 3;
|
||||||
|
}
|
||||||
|
oneof value {
|
||||||
|
Payload payload = 1;
|
||||||
|
Inner inner = 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message PushResponse {}
|
||||||
|
|
||||||
|
message PullRequest {
|
||||||
|
string src_path = 2;
|
||||||
|
string dst_path = 3;
|
||||||
|
FileContainer container = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message PullResponse {
|
||||||
|
Payload payload = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TailRequest {
|
||||||
|
message Start {
|
||||||
|
FileContainer container = 1;
|
||||||
|
string path = 2;
|
||||||
|
}
|
||||||
|
message Stop {}
|
||||||
|
oneof control {
|
||||||
|
Start start = 1;
|
||||||
|
Stop stop = 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message TailResponse {
|
||||||
|
bytes data = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DebuggerInfo {
|
||||||
|
uint64 pid = 1;
|
||||||
|
string host = 2;
|
||||||
|
uint64 port = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SendNotificationRequest {
|
||||||
|
string bundle_id = 1;
|
||||||
|
string json_payload = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SendNotificationResponse {}
|
||||||
|
|
||||||
|
message DapRequest {
|
||||||
|
message Start {
|
||||||
|
string debugger_pkg_id = 1;
|
||||||
|
}
|
||||||
|
message Pipe {
|
||||||
|
bytes data = 1;
|
||||||
|
}
|
||||||
|
message Stop {}
|
||||||
|
oneof control {
|
||||||
|
Start start = 1;
|
||||||
|
Pipe pipe = 2;
|
||||||
|
Stop stop = 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message DapResponse {
|
||||||
|
message Event {
|
||||||
|
string desc = 1;
|
||||||
|
}
|
||||||
|
message Pipe {
|
||||||
|
bytes data = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
oneof event {
|
||||||
|
Event started = 1;
|
||||||
|
Pipe stdout = 2;
|
||||||
|
Event stopped = 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message SimulateMemoryWarningRequest {}
|
||||||
|
|
||||||
|
message SimulateMemoryWarningResponse {}
|
||||||
@@ -786,7 +786,7 @@ class IosDriverBackend(private val udid: String) : DriverBackend {
|
|||||||
tempFileHandler = tempFileHandler,
|
tempFileHandler = tempFileHandler,
|
||||||
)
|
)
|
||||||
val driverConfig = xcuitest.installer.LocalXCTestInstaller.IOSDriverConfig(
|
val driverConfig = xcuitest.installer.LocalXCTestInstaller.IOSDriverConfig(
|
||||||
prebuiltRunner = false,
|
prebuiltRunner = true,
|
||||||
sourceDirectory = "driver-iPhoneSimulator",
|
sourceDirectory = "driver-iPhoneSimulator",
|
||||||
context = xcuitest.installer.Context.CLI,
|
context = xcuitest.installer.Context.CLI,
|
||||||
snapshotKeyHonorModalViews = null,
|
snapshotKeyHonorModalViews = null,
|
||||||
|
|||||||
Reference in new issue
Block a user