diff --git a/.gitignore b/.gitignore index a7d7fea..a42af9c 100644 --- a/.gitignore +++ b/.gitignore @@ -72,4 +72,5 @@ examples/folio-web/.vite/ # Personal research notes /research/ -/talk/ \ No newline at end of file +/talk/ +keys/* diff --git a/cmd/sanderling/doctor.go b/cmd/sanderling/doctor.go index 40072ce..d642c3e 100644 --- a/cmd/sanderling/doctor.go +++ b/cmd/sanderling/doctor.go @@ -14,6 +14,8 @@ import ( "github.com/chromedp/chromedp" + "github.com/priyanshujain/sanderling/internal/driver/ioscompanion" + "github.com/priyanshujain/sanderling/internal/ios" "github.com/priyanshujain/sanderling/internal/sidecarassets" ) @@ -59,8 +61,9 @@ 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. +// additionally need devicectl, the usbmuxd socket, a connected device, and +// signing credentials, covered by iosDeviceChecks and surfaced through the +// "all" union. func iosChecks() []doctorCheck { return []doctorCheck{ {Name: "xcrun on PATH (ios simulator)", Run: checkExecutableOnPath("xcrun")}, @@ -68,12 +71,18 @@ func iosChecks() []doctorCheck { } } -// iosDeviceChecks covers the extra prerequisites a physical iOS device needs: -// the JVM and a real sidecar JAR for the sidecar driver path. +// iosDeviceChecks covers the prerequisites a physical iOS device needs: the +// runner is built and driven over a native usbmux tunnel, so devicectl installs +// the app, the macOS usbmuxd socket carries the tunnel, a device must be +// connected and paired, and App Store Connect signing credentials must be +// present for the no-UI build. Everything here is part of macOS + Xcode; nothing +// is installed. func iosDeviceChecks() []doctorCheck { return []doctorCheck{ - {Name: "java 17+ on PATH (ios physical device)", Run: checkJavaVersion}, - {Name: "sidecar JAR is real (ios physical device)", Run: checkSidecarJAR}, + {Name: "devicectl available (ios physical device)", Run: checkDevicectl}, + {Name: "usbmuxd socket present (ios physical device)", Run: checkUsbmuxd}, + {Name: "an iOS device is connected and paired", Run: checkDeviceConnected}, + {Name: "App Store Connect signing credentials present", Run: checkDeviceSigning}, } } @@ -120,6 +129,48 @@ func checkSimctl(ctx context.Context) error { return nil } +// Device-check seams: package-level so the doctor's device checks run against +// canned results instead of a real device. +var ( + doctorConnectedDevices = ios.ConnectedDevices + doctorVerifySigning = ioscompanion.VerifyDeviceSigning + doctorVerifyUsbmuxd = ioscompanion.VerifyUsbmuxdSocket +) + +// checkDevicectl exercises `xcrun devicectl --version`: devicectl is an xcrun +// subcommand, so a PATH lookup cannot find it. +func checkDevicectl(ctx context.Context) error { + if err := exec.CommandContext(ctx, "xcrun", "devicectl", "--version").Run(); err != nil { + return fmt.Errorf("xcrun devicectl --version: %w", err) + } + return nil +} + +// checkUsbmuxd confirms the macOS usbmuxd socket is present: the native device +// tunnel speaks to it directly instead of shelling out to a third-party client. +func checkUsbmuxd(_ context.Context) error { + return doctorVerifyUsbmuxd() +} + +// checkDeviceConnected confirms at least one physical iOS device is connected +// and paired, the prerequisite for the tunnel and the install. +func checkDeviceConnected(ctx context.Context) error { + devices, err := doctorConnectedDevices(ctx) + if err != nil { + return err + } + if len(devices) == 0 { + return fmt.Errorf("no connected iOS device; connect and pair an iPhone") + } + return nil +} + +// checkDeviceSigning confirms the App Store Connect signing environment is +// complete and the key file exists, so the no-UI device build can sign. +func checkDeviceSigning(_ context.Context) error { + return doctorVerifySigning() +} + func checkSidecarJAR(_ context.Context) error { if sidecarassets.IsPlaceholder() { return fmt.Errorf("placeholder JAR embedded; run `make sidecar && make sanderling` to embed the real fat JAR") diff --git a/cmd/sanderling/doctor_test.go b/cmd/sanderling/doctor_test.go index d6c464d..3155180 100644 --- a/cmd/sanderling/doctor_test.go +++ b/cmd/sanderling/doctor_test.go @@ -8,6 +8,8 @@ import ( "io" "strings" "testing" + + "github.com/priyanshujain/sanderling/internal/ios" ) func TestRunDoctorChecks_AllPass(t *testing.T) { @@ -142,15 +144,58 @@ func TestDoctorChecksFor_iOSSimulator_OmitsJava(t *testing.T) { } } -func TestDoctorChecksFor_iOSDevice_IncludesJava(t *testing.T) { - found := false - for _, c := range doctorChecksFor("ios-device") { - if strings.Contains(c.Name, "java") { - found = true +func TestDoctorChecksFor_iOSDevice_CoversDevicePrereqs(t *testing.T) { + checks := doctorChecksFor("ios-device") + for _, c := range checks { + if strings.Contains(c.Name, "java") || strings.Contains(c.Name, "sidecar") { + t.Errorf("device checks must not include the retired %q", c.Name) } } - if !found { - t.Error("ios-device checks must include java for the sidecar path") + for _, want := range []string{"devicectl", "usbmuxd", "connected and paired", "signing credentials"} { + found := false + for _, c := range checks { + if strings.Contains(c.Name, want) { + found = true + } + } + if !found { + t.Errorf("ios-device checks missing %q: %+v", want, checks) + } + } +} + +func TestCheckDeviceConnected(t *testing.T) { + original := doctorConnectedDevices + t.Cleanup(func() { doctorConnectedDevices = original }) + + doctorConnectedDevices = func(context.Context) ([]ios.Device, error) { + return []ios.Device{{Name: "iPhone"}}, nil + } + if err := checkDeviceConnected(context.Background()); err != nil { + t.Fatalf("a connected device must pass: %v", err) + } + + doctorConnectedDevices = func(context.Context) ([]ios.Device, error) { return nil, nil } + if err := checkDeviceConnected(context.Background()); err == nil { + t.Fatal("no device must fail") + } +} + +func TestCheckDeviceSigning_SurfacesSeamResult(t *testing.T) { + // checkDeviceSigning is a passthrough to the driver's credential check; the + // credential logic itself is covered by TestReadSigningCredentials* in the + // ioscompanion package. Here we only confirm the wiring through the seam. + original := doctorVerifySigning + t.Cleanup(func() { doctorVerifySigning = original }) + + doctorVerifySigning = func() error { return nil } + if err := checkDeviceSigning(context.Background()); err != nil { + t.Fatalf("a passing signing check must surface nil: %v", err) + } + + doctorVerifySigning = func() error { return errors.New("missing credentials") } + if err := checkDeviceSigning(context.Background()); err == nil { + t.Fatal("a failing signing check must surface the error") } } diff --git a/cmd/sanderling/main.go b/cmd/sanderling/main.go index 16b0088..33d8589 100644 --- a/cmd/sanderling/main.go +++ b/cmd/sanderling/main.go @@ -52,8 +52,8 @@ func parseTestArgs(args []string, stderr io.Writer) (testOptions, error) { flagSet.StringVar(&options.bundleID, "bundle-id", "", "target app bundle ID (required)") flagSet.StringVar(&options.platform, "platform", "android", "target platform: android, ios, web") flagSet.StringVar(&options.avd, "avd", "", "Android AVD name to boot if no device is connected") - flagSet.StringVar(&options.iosDevice, "ios-device", "", "iOS simulator name or UDID to boot if none is running") - flagSet.StringVar(&options.iosAppPath, "ios-app-path", "", "path to the .app bundle for iOS simulator clear-state reinstall") + flagSet.StringVar(&options.iosDevice, "ios-device", "", "iOS target: a simulator name/UDID to boot, or a connected device's name, UDID, or CoreDevice id") + flagSet.StringVar(&options.iosAppPath, "ios-app-path", "", "path to the .app bundle for iOS clear-state reinstall (simulator: simctl; device: devicectl)") flagSet.DurationVar(&options.duration, "duration", 5*time.Minute, "total test duration") flagSet.Int64Var(&options.seed, "seed", 0, "RNG seed (0 = random)") flagSet.StringVar(&options.output, "output", "./runs", "output directory for traces") diff --git a/companion/Sources/AppLifecycle.swift b/companion/Sources/AppLifecycle.swift index d7f8770..065abc5 100644 --- a/companion/Sources/AppLifecycle.swift +++ b/companion/Sources/AppLifecycle.swift @@ -31,6 +31,29 @@ enum AppLifecycle { } } + // state reports the app's run state in the vocabulary the Go transport maps + // to a process state: foreground, background, or notRunning. On the + // simulator the hybrid never calls it; on device it backs ForegroundApp. + static func state(bundleIdentifier: String) -> String { + var result = "notRunning" + let collect = { + switch XCUIApplication(bundleIdentifier: bundleIdentifier).state { + case .runningForeground: + result = "foreground" + case .runningBackground, .runningBackgroundSuspended: + result = "background" + default: + result = "notRunning" + } + } + if Thread.isMainThread { + collect() + } else { + DispatchQueue.main.sync(execute: collect) + } + return result + } + // onMainCatching runs automation work on the main thread and converts a // framework assertion into a thrown error so the server survives it. private static func onMainCatching(_ work: @escaping () -> Void) throws { diff --git a/companion/Sources/Server.swift b/companion/Sources/Server.swift index 1d67f94..0f84cc4 100644 --- a/companion/Sources/Server.swift +++ b/companion/Sources/Server.swift @@ -122,6 +122,19 @@ final class Server { let bundleIdentifier = params["bundleId"] as? String ?? currentBundleIdentifier try TextInput.type(text: text, replace: replace, bundleIdentifier: bundleIdentifier) return ["ok": true] + case "eraseText": + let count = params["count"] as? Int ?? 0 + let bundleIdentifier = params["bundleId"] as? String ?? currentBundleIdentifier + try TextInput.erase(count: count, bundleIdentifier: bundleIdentifier) + return ["ok": true] + case "pressKey": + let key = params["key"] as? String ?? "" + let bundleIdentifier = params["bundleId"] as? String ?? currentBundleIdentifier + try TextInput.pressKey(key: key, bundleIdentifier: bundleIdentifier) + return ["ok": true] + case "appState": + let bundleIdentifier = params["bundleId"] as? String ?? currentBundleIdentifier + return ["state": AppLifecycle.state(bundleIdentifier: bundleIdentifier)] case "screenshot": return try screenshot() default: diff --git a/companion/Sources/TextInput.swift b/companion/Sources/TextInput.swift index 0efcf3f..2641fae 100644 --- a/companion/Sources/TextInput.swift +++ b/companion/Sources/TextInput.swift @@ -37,6 +37,40 @@ enum TextInput { } } + // erase deletes count characters from the focused field by typing that many + // delete keys. Used on device, where EraseText routes to the runner instead + // of the legacy companion's HID stream. + static func erase(count: Int, bundleIdentifier: String) throws { + guard count > 0 else { return } + let deletes = String(repeating: XCUIKeyboardKey.delete.rawValue, count: count) + try typeOnFocus(deletes, bundleIdentifier: bundleIdentifier) + } + + // pressKey types a single logical key into the focused field. Only return is + // supported, matching the simulator companion's key surface. + static func pressKey(key: String, bundleIdentifier: String) throws { + switch key { + case "return", "enter", "Return", "Enter": + try typeOnFocus(XCUIKeyboardKey.return.rawValue, bundleIdentifier: bundleIdentifier) + default: + throw TextInputError.typingFailed("unsupported key \(key)") + } + } + + private static func typeOnFocus(_ payload: String, bundleIdentifier: String) throws { + let application = XCUIApplication(bundleIdentifier: bundleIdentifier) + var caughtError: NSError? + var completed = false + runOnMain { + completed = CompanionRunCatching({ + application.typeText(payload) + }, &caughtError) + } + if !completed { + throw TextInputError.typingFailed(caughtError?.localizedDescription ?? "unknown") + } + } + private static func runOnMain(_ work: () -> Void) { if Thread.isMainThread { work() diff --git a/conformance/gates.sh b/conformance/gates.sh index 81d580b..947b82d 100755 --- a/conformance/gates.sh +++ b/conformance/gates.sh @@ -7,8 +7,8 @@ # 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="" (passed as --ios-device) +# driver's runner-only device path; select it +# with IOS_DEVICE="" (passed as --ios-device) # # Usage: # ./gates.sh run the simulator gates @@ -37,7 +37,13 @@ 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 built app bundle differs by SDK: the simulator build lands under +# Debug-iphonesimulator, the device build under Debug-iphoneos. +if [[ "$BACKEND" == "device" ]]; then + ios_app="${folio_directory}/app/iosApp/build/Build/Products/Debug-iphoneos/iosApp.app" +else + ios_app="${folio_directory}/app/iosApp/build/Build/Products/Debug-iphonesimulator/iosApp.app" +fi # The companion binary, embedded for simulator runs. Referenced by file name # only for the orphan-process check; prose elsewhere says "the companion". @@ -187,7 +193,8 @@ 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. +# the device runner session. The usbmux tunnel is an in-process forwarder that +# dies with sanderling, so it leaves no process to check. Empty output is clean. orphan_processes() { local found="" if pgrep -f "$companion_process_name" >/dev/null 2>&1; then @@ -200,9 +207,10 @@ orphan_processes() { 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 " + # The device test session that hosts the runner. Its destination carries + # platform=iOS,id=. + if pgrep -f "xctestrun.*platform=iOS,id=" >/dev/null 2>&1; then + found+="device-session " fi fi printf '%s' "$found" @@ -215,15 +223,11 @@ invoke_sanderling() { 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 + # Both backends pass --ios-app-path so each run reinstalls the current build + # for a clean clear-state start (device install via devicectl, simulator via + # simctl). The device backend selects the connected iPhone by name; the + # simulator backend boots IOS_DEVICE if nothing is booted. + local target_flags=(--ios-device "$IOS_DEVICE" --ios-app-path "$ios_app") local status=0 "$SANDERLING" test \ @@ -254,6 +258,9 @@ run_gates() { if [[ "$BACKEND" == "simulator" ]]; then echo "preparing folio build for the simulator backend" ( cd "$folio_directory" && just ios >/dev/null ) + else + echo "preparing folio device build" + ( cd "$folio_directory" && just ios-device >/dev/null ) fi local timestamp diff --git a/docs/manual/cli.md b/docs/manual/cli.md index 0faf506..73c15da 100644 --- a/docs/manual/cli.md +++ b/docs/manual/cli.md @@ -19,11 +19,12 @@ Run a spec against an app for a fixed duration. | `--launcher-activity` | resolved | Optional `/` to launch. Overrides default resolution. | | `--platform` | `android` | Target platform: `android`, `ios`, or `web`. | | `--avd` | optional (android) | Android AVD name to boot if no device is connected. Required only when no device is connected and multiple AVDs exist. | -| `--ios-device` | optional (ios) | iOS simulator name or UDID to boot if none is running. | +| `--ios-device` | optional (ios) | iOS target: a simulator name/UDID to boot, or a connected device's name, UDID, or CoreDevice id. | +| `--ios-app-path` | optional (ios) | Path to the `.app` bundle for clear-state reinstall (simulator via `simctl`, device via `devicectl`). | | `--duration` | `5m` | Total test duration (`30s`, `5m`, `2h`, `1d`). | | `--seed` | `0` | PRNG seed. `0` uses a random seed and records it in `meta.json`. | | `--output` | `./runs` | Output directory for traces. | -| `--clear-data` | `false` | Clear app data before launching so the run starts from a fresh install. | +| `--clear-data` | `true` | Clear app data before launching so the run starts from a fresh install. Pass `--clear-data=false` to resume prior state. | ## `sanderling replay [run-or-runs-dir]` @@ -42,7 +43,7 @@ See [the replay UI page](./replay/) for the panel reference and keyboard shortcu Check the host environment for a working sanderling setup. ``` -sanderling doctor [--platform web|android|ios|all] +sanderling doctor [--platform web|android|ios|ios-device|all] ``` `--platform` defaults to `all`, which runs every platform's checks (deduped). Pass a specific platform to scope the output. @@ -51,7 +52,8 @@ sanderling doctor [--platform web|android|ios|all] |---|---| | `web` | headless Chromium can launch (the bundled CDP surface boots a real browser). | | `android` | `adb` on PATH; `emulator` on PATH or under `ANDROID_HOME`; Java 17+; embedded native sidecar JAR is real. | -| `ios` | `xcrun` on PATH; `simctl` on PATH; Java 17+; embedded native sidecar JAR is real. | +| `ios` | `xcrun` on PATH; `simctl` on PATH. The simulator path drives the native companion with no JVM. | +| `ios-device` | the `ios` checks plus `devicectl`; the macOS `usbmuxd` socket; a connected, paired device; App Store Connect signing credentials present. | ## `sanderling version` diff --git a/docs/manual/getting-started.md b/docs/manual/getting-started.md index b0fe8e4..fde52dd 100644 --- a/docs/manual/getting-started.md +++ b/docs/manual/getting-started.md @@ -65,6 +65,21 @@ IOS_DEVICE="iPhone 15" just test-ios # pick a different simulator `just test-ios` boots the simulator if needed, builds and installs the app, then runs `sanderling test --platform ios`. +#### Physical device + +A connected iPhone is driven over a usbmux tunnel by a runner the driver builds and signs at run time. The tunnel talks to macOS's own `usbmuxd`, so nothing extra is installed beyond Xcode. It needs App Store Connect signing credentials in the environment (a gitignored `.env` is loaded by `just`): + +```sh +SANDERLING_IOS_TEAM=<10-char team id> +ASC_API_KEY_ID= +ASC_API_ISSUER_ID= +ASC_API_KEY_PATH= + +IOS_DEVICE="iPhone" just test-ios-device # name, UDID, or CoreDevice id +``` + +Run `sanderling doctor --platform ios-device` to check `devicectl`, the `usbmuxd` socket, a connected and paired device, and the signing credentials before a run. + ### Web From either example. For the KMP wasmJs build, use `examples/folio`: diff --git a/examples/folio/app/androidApp/src/main/AndroidManifest.xml b/examples/folio/app/androidApp/src/main/AndroidManifest.xml index c94068c..0001cc9 100644 --- a/examples/folio/app/androidApp/src/main/AndroidManifest.xml +++ b/examples/folio/app/androidApp/src/main/AndroidManifest.xml @@ -2,6 +2,7 @@ Folio +