From 06ea651c71a862960c0d2119a83608210fb8fae6 Mon Sep 17 00:00:00 2001 From: PJ Date: Mon, 8 Jun 2026 23:15:27 +0530 Subject: [PATCH] test(ioscompanion): cover device wiring, routing, and shell-out argv Seam-driven NewDevice wiring + gesture/text routing (asserting no keyboard HID), devicectl/build/test/iproxy argv builders, xctestrun test-target dict name parsing, signing-credential env checks, and source-hash cache keying. --- internal/driver/ioscompanion/device_test.go | 217 ++++++++++++++++++ .../driver/ioscompanion/devicerunner_test.go | 175 ++++++++++++++ 2 files changed, 392 insertions(+) create mode 100644 internal/driver/ioscompanion/device_test.go create mode 100644 internal/driver/ioscompanion/devicerunner_test.go diff --git a/internal/driver/ioscompanion/device_test.go b/internal/driver/ioscompanion/device_test.go new file mode 100644 index 0000000..d4d9c60 --- /dev/null +++ b/internal/driver/ioscompanion/device_test.go @@ -0,0 +1,217 @@ +package ioscompanion + +import ( + "bytes" + "context" + "net" + "os/exec" + "testing" + + "github.com/priyanshujain/sanderling/internal/driver/ioscompanion/transport" +) + +// startLoopbackListener accepts and immediately closes connections so +// waitForListener succeeds against a real address without a real runner. +func startLoopbackListener(t *testing.T) string { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { listener.Close() }) + go func() { + for { + conn, acceptErr := listener.Accept() + if acceptErr != nil { + return + } + _ = conn.Close() + } + }() + return listener.Addr().String() +} + +func testDeviceOptions(address string, companion transport.Companion) DeviceOptions { + return DeviceOptions{ + HardwareUDID: "00008140-HW", + CoreDeviceID: "CORE-1", + BundleID: "com.example.app", + Output: &bytes.Buffer{}, + spawnRunner: func(context.Context, string) (*exec.Cmd, error) { return &exec.Cmd{}, nil }, + spawnTunnel: func(context.Context, string, string, string) (*exec.Cmd, error) { return &exec.Cmd{}, nil }, + dialRunner: func(string) (transport.Companion, error) { return companion, nil }, + pickAddress: func() (string, error) { return address, nil }, + } +} + +func newDeviceCompanion() *fakeTextEditingCompanion { + companion := &fakeTextEditingCompanion{} + companion.accessibilityJSON = "[]" + companion.describe = transport.ScreenDescription{WidthPoints: 393, HeightPoints: 852} + return companion +} + +func TestNewDeviceWiresRunnerOnlyMode(t *testing.T) { + address := startLoopbackListener(t) + companion := newDeviceCompanion() + d, err := NewDevice(context.Background(), testDeviceOptions(address, companion)) + if err != nil { + t.Fatalf("NewDevice: %v", err) + } + defer d.Close() + + if !d.deviceMode { + t.Fatal("deviceMode must be true") + } + if d.hybrid { + t.Fatal("device mode must not be hybrid") + } + if d.runnerClient != nil { + t.Fatal("device mode must leave runnerClient nil so InputText avoids the hybrid HID chord") + } + if d.companion != companion { + t.Fatal("d.companion must be the runner dialed over the tunnel") + } + if d.coreDeviceID != "CORE-1" { + t.Fatalf("coreDeviceID = %q, want CORE-1", d.coreDeviceID) + } + if d.screenWidth != 393 || d.screenHeight != 852 { + t.Fatalf("screen = %dx%d, want 393x852", d.screenWidth, d.screenHeight) + } +} + +func TestNewDeviceRequiresIdentifiers(t *testing.T) { + if _, err := NewDevice(context.Background(), DeviceOptions{CoreDeviceID: "x"}); err == nil { + t.Fatal("missing HardwareUDID must error") + } + if _, err := NewDevice(context.Background(), DeviceOptions{HardwareUDID: "x"}); err == nil { + t.Fatal("missing CoreDeviceID must error") + } +} + +func TestDeviceInputTextUsesNativeEditorNotKeyboardHID(t *testing.T) { + address := startLoopbackListener(t) + companion := newDeviceCompanion() + d, err := NewDevice(context.Background(), testDeviceOptions(address, companion)) + if err != nil { + t.Fatal(err) + } + defer d.Close() + + if err := d.InputText(context.Background(), "héllo 🌟"); err != nil { + t.Fatal(err) + } + if len(companion.inputTexts) != 1 || companion.inputTexts[0] != "héllo 🌟" { + t.Fatalf("inputTexts = %v, want native replace of the text", companion.inputTexts) + } + for _, call := range companion.recorded() { + if call == "hid" { + t.Fatal("device text must go through the native editor, never a keyboard HID chord") + } + } +} + +func TestDeviceGesturesRouteTouchHIDToRunner(t *testing.T) { + address := startLoopbackListener(t) + companion := newDeviceCompanion() + d, err := NewDevice(context.Background(), testDeviceOptions(address, companion)) + if err != nil { + t.Fatal(err) + } + defer d.Close() + + if err := d.DoubleTap(context.Background(), 10, 20); err != nil { + t.Fatal(err) + } + if indexOf(companion.recorded(), "hid") < 0 { + t.Fatalf("gesture must send touch HID to the runner; got %v", companion.recorded()) + } +} + +func TestDeviceEraseAndPressKeyRouteThroughEditor(t *testing.T) { + address := startLoopbackListener(t) + companion := newDeviceCompanion() + d, err := NewDevice(context.Background(), testDeviceOptions(address, companion)) + if err != nil { + t.Fatal(err) + } + defer d.Close() + + if err := d.EraseText(context.Background(), 4); err != nil { + t.Fatal(err) + } + if err := d.PressKey(context.Background(), "enter"); err != nil { + t.Fatal(err) + } + if len(companion.eraseCounts) != 1 || companion.eraseCounts[0] != 4 { + t.Fatalf("eraseCounts = %v, want [4]", companion.eraseCounts) + } + if len(companion.pressedKeys) != 1 || companion.pressedKeys[0] != "enter" { + t.Fatalf("pressedKeys = %v, want [enter]", companion.pressedKeys) + } +} + +func TestDeviceClearStateWithoutAppPathWarnsOnce(t *testing.T) { + output := &bytes.Buffer{} + d := &Driver{output: output, deviceMode: true} + d.resetContainer = d.deviceResetContainerUnsupported + for i := 0; i < 2; i++ { + if err := d.deviceResetContainerUnsupported(context.Background()); err != nil { + t.Fatal(err) + } + } + if got := bytes.Count(output.Bytes(), []byte("requires --ios-app-path")); got != 1 { + t.Fatalf("warning emitted %d times, want once", got) + } +} + +func TestDeviceCloseStopsRunnerAndTunnel(t *testing.T) { + d := &Driver{output: &bytes.Buffer{}} + runner := exec.Command("sleep", "30") + tunnel := exec.Command("sleep", "30") + if err := runner.Start(); err != nil { + t.Fatal(err) + } + if err := tunnel.Start(); err != nil { + t.Fatal(err) + } + d.runnerChild = runner + d.tunnelChild = tunnel + + d.Close() + + if d.runnerChild != nil || d.tunnelChild != nil { + t.Fatal("Close must clear both child fields") + } + if runner.ProcessState == nil { + t.Fatal("Close must reap the runner session child") + } + if tunnel.ProcessState == nil { + t.Fatal("Close must reap the tunnel child") + } +} + +func TestDevicectlReinstallArgs(t *testing.T) { + uninstall := devicectlUninstallArgs("CORE-1", "com.example.app") + wantUninstall := []string{"devicectl", "device", "uninstall", "app", "--device", "CORE-1", "com.example.app"} + if !equalArgs(uninstall, wantUninstall) { + t.Fatalf("uninstall args = %v, want %v", uninstall, wantUninstall) + } + install := devicectlInstallArgs("CORE-1", "/tmp/app.app") + wantInstall := []string{"devicectl", "device", "install", "app", "--device", "CORE-1", "/tmp/app.app"} + if !equalArgs(install, wantInstall) { + t.Fatalf("install args = %v, want %v", install, wantInstall) + } +} + +func equalArgs(got, want []string) bool { + if len(got) != len(want) { + return false + } + for i := range got { + if got[i] != want[i] { + return false + } + } + return true +} diff --git a/internal/driver/ioscompanion/devicerunner_test.go b/internal/driver/ioscompanion/devicerunner_test.go new file mode 100644 index 0000000..a780b85 --- /dev/null +++ b/internal/driver/ioscompanion/devicerunner_test.go @@ -0,0 +1,175 @@ +package ioscompanion + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestBuildForTestingArgs(t *testing.T) { + creds := signingCredentials{team: "TEAM1", authKeyPath: "/k/AuthKey.p8", authKeyID: "KID", authIssuerID: "ISS"} + args := buildForTestingArgs("/p/CompanionRunner.xcodeproj", "/d/derived", creds) + joined := strings.Join(args, " ") + for _, want := range []string{ + "xcodebuild build-for-testing", + "-project /p/CompanionRunner.xcodeproj", + "-scheme CompanionRunner", + "-destination generic/platform=iOS", + "-derivedDataPath /d/derived", + "-allowProvisioningUpdates", + "-authenticationKeyPath /k/AuthKey.p8", + "-authenticationKeyID KID", + "-authenticationKeyIssuerID ISS", + "CODE_SIGN_STYLE=Automatic", + "DEVELOPMENT_TEAM=TEAM1", + "GENERATE_INFOPLIST_FILE=YES", + } { + if !strings.Contains(joined, want) { + t.Errorf("build args missing %q in %q", want, joined) + } + } +} + +func TestTestWithoutBuildingArgs(t *testing.T) { + creds := signingCredentials{team: "TEAM1", authKeyPath: "/k/AuthKey.p8", authKeyID: "KID", authIssuerID: "ISS"} + args := testWithoutBuildingArgs("/x/run.xctestrun", "00008140-HW", creds) + joined := strings.Join(args, " ") + for _, want := range []string{ + "xcodebuild test-without-building", + "-xctestrun /x/run.xctestrun", + "-destination platform=iOS,id=00008140-HW", + "-allowProvisioningUpdates", + "-authenticationKeyPath /k/AuthKey.p8", + "-authenticationKeyID KID", + "-authenticationKeyIssuerID ISS", + } { + if !strings.Contains(joined, want) { + t.Errorf("test args missing %q in %q", want, joined) + } + } +} + +func TestIproxyArgs(t *testing.T) { + args := iproxyArgs("49200", "27753", "00008140-HW") + want := []string{"iproxy", "49200:27753", "-u", "00008140-HW"} + if !equalArgs(args, want) { + t.Fatalf("iproxy args = %v, want %v", args, want) + } +} + +func TestXcodegenArgs(t *testing.T) { + args := xcodegenArgs("/c/project.yml") + want := []string{"xcodegen", "--spec", "/c/project.yml"} + if !equalArgs(args, want) { + t.Fatalf("xcodegen args = %v, want %v", args, want) + } +} + +func TestTestTargetNameFromJSON(t *testing.T) { + // A device xctestrun-as-json: one test-target dict plus the metadata entry. + data := []byte(`{ + "CompanionRunnerUITests": {"EnvironmentVariables": {}}, + "__xctestrun_metadata__": {"FormatVersion": 1} + }`) + name, err := testTargetNameFromJSON(data) + if err != nil { + t.Fatal(err) + } + if name != "CompanionRunnerUITests" { + t.Fatalf("name = %q, want CompanionRunnerUITests", name) + } +} + +func TestTestTargetNameFromJSON_IgnoresCodeCoverageInfo(t *testing.T) { + data := []byte(`{ + "CompanionRunnerUITests": {}, + "CodeCoverageBuildableInfos": {}, + "__xctestrun_metadata__": {} + }`) + name, err := testTargetNameFromJSON(data) + if err != nil { + t.Fatal(err) + } + if name != "CompanionRunnerUITests" { + t.Fatalf("name = %q, want CompanionRunnerUITests", name) + } +} + +func TestTestTargetNameFromJSON_MultipleTargetsError(t *testing.T) { + data := []byte(`{"A": {}, "B": {}, "__xctestrun_metadata__": {}}`) + if _, err := testTargetNameFromJSON(data); err == nil { + t.Fatal("multiple test-target dicts must error so the wrong dict is never patched") + } +} + +func TestReadSigningCredentialsReportsMissing(t *testing.T) { + for _, key := range []string{envTeam, envTeamFallback, envAuthKeyPath, envAuthKeyID, envAuthIssuer} { + t.Setenv(key, "") + } + _, err := readSigningCredentials() + if err == nil { + t.Fatal("missing credentials must error") + } + for _, want := range []string{envTeam, envAuthKeyPath, envAuthKeyID, envAuthIssuer} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error should name missing var %q: %v", want, err) + } + } +} + +func TestReadSigningCredentialsRejectsMissingKeyFile(t *testing.T) { + t.Setenv(envTeam, "TEAM1") + t.Setenv(envAuthKeyID, "KID") + t.Setenv(envAuthIssuer, "ISS") + t.Setenv(envAuthKeyPath, filepath.Join(t.TempDir(), "absent.p8")) + if _, err := readSigningCredentials(); err == nil { + t.Fatal("a missing .p8 key file must error") + } +} + +func TestReadSigningCredentialsAcceptsPresentKey(t *testing.T) { + keyPath := filepath.Join(t.TempDir(), "AuthKey.p8") + if err := os.WriteFile(keyPath, []byte("-----BEGIN PRIVATE KEY-----"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv(envTeam, "") + t.Setenv(envTeamFallback, "FALLBACKTEAM") + t.Setenv(envAuthKeyID, "KID") + t.Setenv(envAuthIssuer, "ISS") + t.Setenv(envAuthKeyPath, keyPath) + creds, err := readSigningCredentials() + if err != nil { + t.Fatal(err) + } + if creds.team != "FALLBACKTEAM" { + t.Fatalf("team = %q, want the DEVELOPMENT_TEAM fallback", creds.team) + } +} + +func TestSourceHashChangesWithSources(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "Sources"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "Sources", "Server.swift"), []byte("v1"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "project.yml"), []byte("name: x"), 0o644); err != nil { + t.Fatal(err) + } + first, err := sourceHash(dir) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "Sources", "Server.swift"), []byte("v2"), 0o644); err != nil { + t.Fatal(err) + } + second, err := sourceHash(dir) + if err != nil { + t.Fatal(err) + } + if first == second { + t.Fatal("a source edit must change the hash so the cached build is invalidated") + } +}