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

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

* chore(ioscompanion): add companion asset prepare script

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

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

* docs: add third party notices for vendored companion

* chore: ignore vendored companion bundle artifact

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

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

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

* feat(ioscompanion): commit generated companion gRPC stubs

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

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

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

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

* feat(ioscompanion): add USB HID keymap module

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

* build: embed companion assets via withcompanion tag

* feat(ioscompanion): add transport companion interface

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

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

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

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

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

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

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

* feat(ioscompanion): add Describe to companion transport

* feat(ioscompanion): implement DeviceDriver with companion supervision

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

* test(ioscompanion): gated companion smoke test

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

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

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

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

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

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

* feat(folio): add iOS conformance gate script

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

* style: gofmt struct alignment drift

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

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

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

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

* test(ioscompanion): cover install payload chunking

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* test(ioscompanion): cover collapsed-dump detection

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

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

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

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

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

* feat(companion): add simulator runner project skeleton

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

* feat(companion): synthesize timestamped touch gestures

* feat(companion): type text with replace semantics

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

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

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

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

* feat(ioscompanion): add runner companion JSON transport

* test(ioscompanion): cover runner transport protocol mapping

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

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

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

* refactor(companion): tidy snapshot serialization

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

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

* chore(companion): add runner asset prepare script

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

* test(ioscompanion): cover runner asset extraction

* build(ioscompanion): commit runner asset archive

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

No files matched your search

+30 -8
View File
@@ -33,6 +33,8 @@ func doctorChecksFor(platform string) []doctorCheck {
return androidChecks()
case "ios":
return iosChecks()
case "ios-device":
return append(iosChecks(), iosDeviceChecks()...)
case "all":
return allChecks()
default:
@@ -55,19 +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 {
return []doctorCheck{
{Name: "xcrun on PATH", Run: checkExecutableOnPath("xcrun")},
{Name: "simctl on PATH", Run: checkExecutableOnPath("simctl")},
{Name: "java 17+ on PATH", Run: checkJavaVersion},
{Name: "sidecar JAR is real (not placeholder)", Run: checkSidecarJAR},
{Name: "xcrun on PATH (ios simulator)", Run: checkExecutableOnPath("xcrun")},
{Name: "simctl available (ios simulator)", Run: checkSimctl},
}
}
// iosDeviceChecks covers the 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 {
seen := map[string]bool{}
var combined []doctorCheck
for _, group := range [][]doctorCheck{webChecks(), androidChecks(), iosChecks()} {
for _, group := range [][]doctorCheck{webChecks(), androidChecks(), iosChecks(), iosDeviceChecks()} {
for _, c := range group {
if seen[c.Name] {
continue
@@ -98,6 +111,15 @@ func checkChromiumLaunch(ctx context.Context) error {
return nil
}
// checkSimctl exercises `xcrun simctl help`: simctl is an xcrun subcommand,
// not a standalone binary, so a PATH lookup can never find it.
func checkSimctl(ctx context.Context) error {
if err := exec.CommandContext(ctx, "xcrun", "simctl", "help").Run(); err != nil {
return fmt.Errorf("xcrun simctl help: %w", err)
}
return nil
}
func checkSidecarJAR(_ context.Context) error {
if sidecarassets.IsPlaceholder() {
return fmt.Errorf("placeholder JAR embedded; run `make sidecar && make sanderling` to embed the real fat JAR")
@@ -116,15 +138,15 @@ func parseDoctorArgs(args []string, stderr io.Writer) (doctorOptions, error) {
flagSet := flag.NewFlagSet("doctor", flag.ContinueOnError)
flagSet.SetOutput(stderr)
var options doctorOptions
flagSet.StringVar(&options.platform, "platform", "all", "target platform: web, android, ios, all")
flagSet.StringVar(&options.platform, "platform", "all", "target platform: web, android, ios, ios-device, all")
if err := flagSet.Parse(args); err != nil {
return doctorOptions{}, err
}
switch options.platform {
case "web", "android", "ios", "all":
case "web", "android", "ios", "ios-device", "all":
return options, nil
default:
return doctorOptions{}, fmt.Errorf("unsupported platform: %q (web, android, ios, all)", options.platform)
return doctorOptions{}, fmt.Errorf("unsupported platform: %q (web, android, ios, ios-device, all)", options.platform)
}
}
+21 -1
View File
@@ -127,13 +127,33 @@ func TestDoctorChecksFor_All_IsUnion(t *testing.T) {
for _, c := range all {
names[c.Name]++
}
for _, name := range []string{"adb on PATH", "xcrun on PATH", "headless chromium can launch"} {
for _, name := range []string{"adb on PATH", "xcrun on PATH (ios simulator)", "headless chromium can launch"} {
if names[name] != 1 {
t.Errorf("expected %q in 'all' exactly once, got %d", name, names[name])
}
}
}
func TestDoctorChecksFor_iOSSimulator_OmitsJava(t *testing.T) {
for _, c := range doctorChecksFor("ios") {
if strings.Contains(c.Name, "java") || strings.Contains(c.Name, "sidecar") {
t.Errorf("ios simulator checks must omit %q; simulator runs need no JVM", c.Name)
}
}
}
func TestDoctorChecksFor_iOSDevice_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) {
if got := doctorChecksFor("fuchsia"); got != nil {
t.Errorf("expected nil for unknown platform, got %+v", got)
+18 -10
View File
@@ -8,6 +8,8 @@ import (
"fmt"
"io"
"os"
"os/signal"
"syscall"
"time"
)
@@ -16,15 +18,16 @@ import (
var Version = "dev"
type testOptions struct {
spec string
bundleID string
platform string
avd string
iosDevice string
duration time.Duration
seed int64
output string
clearData bool
spec string
bundleID string
platform string
avd string
iosDevice string
iosAppPath string
duration time.Duration
seed int64
output string
clearData bool
}
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.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.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.Int64Var(&options.seed, "seed", 0, "RNG seed (0 = random)")
flagSet.StringVar(&options.output, "output", "./runs", "output directory for traces")
@@ -72,7 +76,11 @@ func parseTestArgs(args []string, stderr io.Writer) (testOptions, error) {
}
func runTest(options testOptions, stdout io.Writer) error {
ctx, cancel := context.WithCancel(context.Background())
// A signal-aware root context: on Ctrl-C the cancellation propagates into
// the drivers' process contexts, so spawned children (the iOS companion,
// the xcodebuild runner session) get their SIGTERM instead of outliving
// the run as orphans.
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
return runTestPipeline(ctx, options, stdout)
}
+25
View File
@@ -181,6 +181,31 @@ func TestParseTestArgs_IosDeviceFlag(t *testing.T) {
}
}
func TestParseTestArgs_IosAppPathFlag(t *testing.T) {
options, err := parseTestArgs([]string{
"--spec", "s.ts",
"--bundle-id", "com.example.app",
"--platform", "ios",
"--ios-app-path", "/tmp/build/iosApp.app",
}, io.Discard)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if options.iosAppPath != "/tmp/build/iosApp.app" {
t.Errorf("expected iosAppPath=/tmp/build/iosApp.app, got %q", options.iosAppPath)
}
}
func TestParseTestArgs_IosAppPathOptional(t *testing.T) {
options, err := parseTestArgs([]string{"--spec", "s.ts", "--bundle-id", "com.example.app"}, io.Discard)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if options.iosAppPath != "" {
t.Errorf("iosAppPath default: got %q, want empty", options.iosAppPath)
}
}
func TestRun_TestSubcommand_PipelineErrors(t *testing.T) {
// Web skips host-dependent device boot and bundles first, so a missing spec
// deterministically surfaces a bundle-resolution error. This proves the
+10 -9
View File
@@ -9,14 +9,15 @@ import (
func runTestPipeline(ctx context.Context, options testOptions, stdout io.Writer) error {
return testrun.Execute(ctx, testrun.Options{
Spec: options.spec,
BundleID: options.bundleID,
Platform: options.platform,
AVD: options.avd,
IosDevice: options.iosDevice,
Duration: options.duration,
Seed: options.seed,
Output: options.output,
ClearData: options.clearData,
Spec: options.spec,
BundleID: options.bundleID,
Platform: options.platform,
AVD: options.avd,
IosDevice: options.iosDevice,
IosAppPath: options.iosAppPath,
Duration: options.duration,
Seed: options.seed,
Output: options.output,
ClearData: options.clearData,
}, stdout)
}