mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
merge origin/master into llm-recording-and-analysis
both sides independently fixed the same three bugs, so each one had to pick a winner rather than keep both implementations. extractor encoding: master's recordableValue in worker.go wins over ours in marshal.go, since master's is pinned by extractor_encoding_test.go and ours had no tests. our error semantics stay: encodeExtractorValue still returns an error instead of nil, so an extractor cannot vanish from the trace silently. apply errors: only the residual generic branch takes master's unconfirmed copy, where the device may have committed the action before the call failed. the finer branches that know nothing was dispatched keep lastAction = nil, and our actionSkipReason taxonomy stays alongside master's held/skippedVerification. selector matching: our matchAttr with matchSelectorKind wins over master's match, since ours also handles idPrefix. matchSelector now calls it, which git did not flag as a conflict and left calling a function our side had deleted. the ltl doc comment takes master's correction: an unbounded eventually that never fires IS violated at run end.
This commit is contained in:
commit
6e85cac8b3
130 files changed
+12772
-1617
No files matched your search
@@ -31,17 +31,22 @@ type DeviceOptions struct {
|
||||
BundleID string
|
||||
// AppPath is the .app bundle installed via devicectl for clear-state.
|
||||
AppPath string
|
||||
// ClearState reinstalls the app while NewDevice runs, before the runner's
|
||||
// test session exists. Clear state is a property of the driver rather than
|
||||
// of a launch: see Launch.
|
||||
ClearState bool
|
||||
// Output receives the runner session log path and driver warnings.
|
||||
Output io.Writer
|
||||
// DoubleTapGapMilliseconds overrides the synthesized double-tap gap.
|
||||
DoubleTapGapMilliseconds float64
|
||||
|
||||
// Test seams. Production leaves them nil and NewDevice wires the real
|
||||
// build/spawn/tunnel/dial.
|
||||
spawnRunner func(ctx context.Context, address string) (*exec.Cmd, error)
|
||||
startTunnel func(ctx context.Context, hardwareUDID, localAddress, devicePort string) (io.Closer, error)
|
||||
dialRunner func(address string) (transport.Companion, error)
|
||||
pickAddress func() (string, error)
|
||||
// build/spawn/tunnel/dial/devicectl.
|
||||
spawnRunner func(ctx context.Context, address string) (*exec.Cmd, error)
|
||||
startTunnel func(ctx context.Context, hardwareUDID, localAddress, devicePort string) (io.Closer, error)
|
||||
dialRunner func(address string) (transport.Companion, error)
|
||||
pickAddress func() (string, error)
|
||||
reinstallApp func(ctx context.Context) error
|
||||
}
|
||||
|
||||
// deviceStartupTimeout bounds the runner's startup once its hosting test
|
||||
@@ -61,6 +66,9 @@ func NewDevice(ctx context.Context, options DeviceOptions) (*Driver, error) {
|
||||
if options.CoreDeviceID == "" {
|
||||
return nil, errors.New("ios device: CoreDeviceID is required")
|
||||
}
|
||||
if options.ClearState && options.BundleID == "" {
|
||||
return nil, errors.New("ios device: clear-state needs BundleID: there is nothing to uninstall without it")
|
||||
}
|
||||
output := options.Output
|
||||
if output == nil {
|
||||
output = io.Discard
|
||||
@@ -95,17 +103,24 @@ func NewDevice(ctx context.Context, options DeviceOptions) (*Driver, error) {
|
||||
}
|
||||
}
|
||||
if options.pickAddress != nil {
|
||||
d.pickDeviceAddress = options.pickAddress
|
||||
d.pickRunnerAddress = options.pickAddress
|
||||
} else {
|
||||
d.pickDeviceAddress = pickLoopbackAddress
|
||||
d.pickRunnerAddress = pickLoopbackAddress
|
||||
}
|
||||
|
||||
// Device seams: clear-state reinstalls via devicectl; the container reset and
|
||||
// paste grant are simulator-only and become no-ops. The runner types
|
||||
// natively, so no paste prompt is ever hit.
|
||||
d.reinstallApp = d.devicectlReinstall
|
||||
// natively, so no paste prompt is ever hit. Stopping the app before the
|
||||
// clear is a no-op too: devicectl addresses processes by pid rather than by
|
||||
// bundle, and the uninstall that is the device's only clear takes the
|
||||
// running app with it, which is what a terminate here would be for.
|
||||
d.reinstallApp = options.reinstallApp
|
||||
if d.reinstallApp == nil {
|
||||
d.reinstallApp = d.devicectlReinstall
|
||||
}
|
||||
d.resetContainer = d.deviceResetContainerUnsupported
|
||||
d.grantPaste = func(context.Context) error { return nil }
|
||||
d.terminateApp = func(context.Context) error { return nil }
|
||||
d.restart = d.respawnDevice
|
||||
d.processContext, d.processCancel = context.WithCancel(ctx)
|
||||
|
||||
@@ -116,6 +131,14 @@ func NewDevice(ctx context.Context, options DeviceOptions) (*Driver, error) {
|
||||
}
|
||||
d.deviceLock = lock
|
||||
|
||||
if options.ClearState {
|
||||
if err := d.clearAppState(ctx); err != nil {
|
||||
d.Close()
|
||||
return nil, err
|
||||
}
|
||||
d.clearedBundleID = options.BundleID
|
||||
}
|
||||
|
||||
if err := d.bringUpDevice(ctx); err != nil {
|
||||
d.Close()
|
||||
return nil, err
|
||||
@@ -136,7 +159,7 @@ func NewDevice(ctx context.Context, options DeviceOptions) (*Driver, error) {
|
||||
// health. The build runs inside spawnRunner under the process context, so the
|
||||
// startup timeout only bounds the post-spawn wait, not the build.
|
||||
func (d *Driver) bringUpDevice(ctx context.Context) error {
|
||||
address, err := d.pickDeviceAddress()
|
||||
address, err := d.pickRunnerAddress()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -205,8 +228,14 @@ func (d *Driver) respawnDevice(ctx context.Context) error {
|
||||
// devicectlReinstall uninstalls then installs the app bundle via devicectl,
|
||||
// keyed on the CoreDevice id. App lifecycle stays with devicectl: the runner's
|
||||
// own install path is simulator-specific.
|
||||
// A failed uninstall ends the reinstall: installing over an app keeps its data,
|
||||
// so clear-state would be reported without happening. Uninstalling an app that
|
||||
// is not installed exits 0 ("App uninstalled." on a paired iPhone running iOS
|
||||
// 26.5), so there is no benign failure here to sort out from a real one.
|
||||
func (d *Driver) devicectlReinstall(ctx context.Context) error {
|
||||
_ = exec.CommandContext(ctx, "xcrun", "devicectl", "device", "uninstall", "app", "--device", d.coreDeviceID, d.bundleID).Run()
|
||||
if output, err := exec.CommandContext(ctx, "xcrun", "devicectl", "device", "uninstall", "app", "--device", d.coreDeviceID, d.bundleID).CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("devicectl uninstall %s: %w: %s", d.bundleID, err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
output, err := exec.CommandContext(ctx, "xcrun", "devicectl", "device", "install", "app", "--device", d.coreDeviceID, d.appPath).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("devicectl install: %w: %s", err, strings.TrimSpace(string(output)))
|
||||
@@ -214,13 +243,11 @@ func (d *Driver) devicectlReinstall(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// deviceResetContainerUnsupported warns once that device clear-state needs an
|
||||
// app path for a devicectl reinstall: there is no simulator-style data-container
|
||||
// wipe on a physical device.
|
||||
// deviceResetContainerUnsupported ends the run: there is no simulator-style
|
||||
// data-container wipe on a physical device, so a clear-state with no app path
|
||||
// to reinstall from cannot happen. Warning and carrying on hands the run every
|
||||
// previous run's data while the flag says it started clean.
|
||||
func (d *Driver) deviceResetContainerUnsupported(context.Context) error {
|
||||
if !d.clearStateWarned {
|
||||
fmt.Fprintln(d.output, "clear-state on a physical device requires --ios-app-path for a reinstall; skipping (state not cleared)")
|
||||
d.clearStateWarned = true
|
||||
}
|
||||
return nil
|
||||
return errors.New("clear-state on a physical device requires --ios-app-path for a reinstall; " +
|
||||
"there is no data-container wipe on a device, so the run would start on the previous run's state")
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"io"
|
||||
"net"
|
||||
"os/exec"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/priyanshujain/sanderling/internal/driver/ioscompanion/transport"
|
||||
@@ -81,6 +83,25 @@ func TestNewDeviceWiresRunnerOnlyMode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewDeviceWiresTheAddressPickerEveryBringUpUses keeps the device driver
|
||||
// whole. bringUpRunner reads its picker from a field rather than calling the
|
||||
// package function, and NewDevice left that field nil, so the only thing
|
||||
// standing between a device run and a nil call was which restart path ran.
|
||||
func TestNewDeviceWiresTheAddressPickerEveryBringUpUses(t *testing.T) {
|
||||
address := startLoopbackListener(t)
|
||||
options := testDeviceOptions(address, newDeviceCompanion())
|
||||
options.HardwareUDID = "00008140-PICKER"
|
||||
d, err := NewDevice(context.Background(), options)
|
||||
if err != nil {
|
||||
t.Fatalf("NewDevice: %v", err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
if err := d.bringUpRunner(context.Background()); err != nil {
|
||||
t.Fatalf("bringUpRunner: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDeviceRequiresIdentifiers(t *testing.T) {
|
||||
if _, err := NewDevice(context.Background(), DeviceOptions{CoreDeviceID: "x"}); err == nil {
|
||||
t.Fatal("missing HardwareUDID must error")
|
||||
@@ -152,17 +173,100 @@ func TestDeviceEraseAndPressKeyRouteThroughEditor(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
func TestNewDeviceReinstallsOnceBeforeTheRunnerSession(t *testing.T) {
|
||||
address := startLoopbackListener(t)
|
||||
probe := &clearStateProbe{}
|
||||
options := testDeviceOptions(address, newDeviceCompanion())
|
||||
options.HardwareUDID = "00008140-CLEAR"
|
||||
options.AppPath = "/tmp/Sample.app"
|
||||
options.ClearState = true
|
||||
options.reinstallApp = func(context.Context) error { probe.record("reinstall"); return nil }
|
||||
spawn := options.spawnRunner
|
||||
options.spawnRunner = func(ctx context.Context, runnerAddress string) (*exec.Cmd, error) {
|
||||
probe.record("runner session")
|
||||
return spawn(ctx, runnerAddress)
|
||||
}
|
||||
|
||||
d, err := NewDevice(context.Background(), options)
|
||||
if err != nil {
|
||||
t.Fatalf("NewDevice: %v", err)
|
||||
}
|
||||
defer d.Close()
|
||||
if err := d.Launch(context.Background(), "", true, nil); err != nil {
|
||||
t.Fatalf("Launch: %v", err)
|
||||
}
|
||||
|
||||
want := []string{"reinstall", "runner session"}
|
||||
if got := probe.recorded(); !slices.Equal(got, want) {
|
||||
t.Fatalf("calls = %v, want %v: devicectl must reinstall once, before the runner's test session attaches", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevicectlReinstallStopsWhenTheUninstallFails(t *testing.T) {
|
||||
log := scriptedXcrun(t, `"devicectl device uninstall "*) echo "ERROR: Internal logic error: Connection was invalidated"; exit 1;;
|
||||
"devicectl device install "*) :;;`)
|
||||
d := &Driver{coreDeviceID: "CORE-DEVICE", bundleID: "app.example", appPath: "/tmp/Sample.app"}
|
||||
|
||||
err := d.devicectlReinstall(context.Background())
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("devicectlReinstall reported success while app.example kept the data clear-state was asked to remove")
|
||||
}
|
||||
for _, want := range []string{"app.example", "Connection was invalidated"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("error %q does not quote %q", err, want)
|
||||
}
|
||||
}
|
||||
if got := bytes.Count(output.Bytes(), []byte("requires --ios-app-path")); got != 1 {
|
||||
t.Fatalf("warning emitted %d times, want once", got)
|
||||
calls := xcrunCalls(t, log)
|
||||
if slices.ContainsFunc(calls, func(call string) bool { return strings.HasPrefix(call, "devicectl device install") }) {
|
||||
t.Errorf("xcrun calls = %v: installing over the app carries its data into the run", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevicectlReinstallProceedsWhenNothingIsInstalled(t *testing.T) {
|
||||
log := scriptedXcrun(t, `"devicectl device uninstall "*) echo "App uninstalled.";;
|
||||
"devicectl device install "*) :;;`)
|
||||
d := &Driver{coreDeviceID: "CORE-DEVICE", bundleID: "app.example", appPath: "/tmp/Sample.app"}
|
||||
|
||||
if err := d.devicectlReinstall(context.Background()); err != nil {
|
||||
t.Fatalf("devicectlReinstall: %v", err)
|
||||
}
|
||||
|
||||
want := []string{
|
||||
"devicectl device uninstall app --device CORE-DEVICE app.example",
|
||||
"devicectl device install app --device CORE-DEVICE /tmp/Sample.app",
|
||||
}
|
||||
if got := xcrunCalls(t, log); !slices.Equal(got, want) {
|
||||
t.Fatalf("xcrun calls = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewDeviceRefusesClearStateWithoutAnAppPath keeps the device from starting
|
||||
// a run whose clear-state cannot happen. There is no data-container wipe on a
|
||||
// physical device, so without an app path to reinstall from, carrying on hands
|
||||
// the run every previous run's data under a flag that says otherwise.
|
||||
func TestNewDeviceRefusesClearStateWithoutAnAppPath(t *testing.T) {
|
||||
address := startLoopbackListener(t)
|
||||
options := testDeviceOptions(address, newDeviceCompanion())
|
||||
options.HardwareUDID = "00008140-NO-APP-PATH"
|
||||
options.ClearState = true
|
||||
spawned := false
|
||||
spawn := options.spawnRunner
|
||||
options.spawnRunner = func(ctx context.Context, runnerAddress string) (*exec.Cmd, error) {
|
||||
spawned = true
|
||||
return spawn(ctx, runnerAddress)
|
||||
}
|
||||
|
||||
d, err := NewDevice(context.Background(), options)
|
||||
if err == nil {
|
||||
d.Close()
|
||||
t.Fatal("NewDevice returned a driver whose clear-state never happened")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--ios-app-path") {
|
||||
t.Fatalf("err = %v, want it to name the flag that makes the clear possible", err)
|
||||
}
|
||||
if spawned {
|
||||
t.Fatal("the run started anyway; a clear-state that cannot happen must end the run, not open it")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,14 @@ var shutdownGrace = 15 * time.Second
|
||||
// A variable so the timeout test can shrink it.
|
||||
var launchTimeout = 90 * time.Second
|
||||
|
||||
// launchRecoveryTimeout bounds the whole recovery a blown launch bound
|
||||
// triggers, the session restart and the second attempt together. It keeps the
|
||||
// launch path inside the three minutes testrun allows it, so what a user sees
|
||||
// when the app really cannot be launched stays the driver's error rather than
|
||||
// that backstop firing over the top of it. A variable so the bound test can
|
||||
// shrink it.
|
||||
var launchRecoveryTimeout = 60 * time.Second
|
||||
|
||||
// longPressHoldMilliseconds is how long LongPress holds the finger down.
|
||||
const longPressHoldMilliseconds = 600
|
||||
|
||||
@@ -65,16 +73,25 @@ type Options struct {
|
||||
// AppPath is the .app bundle directory. Required for clear-state reinstall;
|
||||
// when empty, clear state falls back to resetting the data container.
|
||||
AppPath string
|
||||
// ClearState resets the app to first-launch state while New runs, before
|
||||
// any automation session attaches. Clear state is a property of the driver
|
||||
// rather than of a launch: see Launch.
|
||||
ClearState bool
|
||||
// Output receives companion stdout and stderr plus driver warnings.
|
||||
Output io.Writer
|
||||
// DoubleTapGapMilliseconds overrides the synthesized double-tap gap.
|
||||
DoubleTapGapMilliseconds float64
|
||||
|
||||
// spawnChild, dialCompanion, and pickAddress are test seams. Production
|
||||
// leaves them nil and New wires the real extraction, spawn, and dial.
|
||||
spawnChild func(ctx context.Context, address string) (*exec.Cmd, error)
|
||||
dialCompanion func(address string) (transport.Companion, error)
|
||||
pickAddress func() (string, error)
|
||||
// These are test seams. Production leaves them nil and New wires the real
|
||||
// extraction, spawn, dial and simctl calls.
|
||||
spawnChild func(ctx context.Context, address string) (*exec.Cmd, error)
|
||||
dialCompanion func(address string) (transport.Companion, error)
|
||||
pickAddress func() (string, error)
|
||||
spawnRunner func(ctx context.Context, address string) (*exec.Cmd, error)
|
||||
dialRunner func(address string) (transport.Companion, error)
|
||||
reinstallApp func(ctx context.Context) error
|
||||
resetContainer func(ctx context.Context) error
|
||||
terminateApp func(ctx context.Context) error
|
||||
}
|
||||
|
||||
// Driver implements driver.DeviceDriver against an iOS simulator companion.
|
||||
@@ -85,6 +102,13 @@ type Driver struct {
|
||||
appPath string
|
||||
output io.Writer
|
||||
|
||||
// clearedBundleID names the app New (or NewDevice) reset to first-launch
|
||||
// state before attaching, which is the only point in a run where clearing
|
||||
// is safe. Launch refuses a clear-state request for anything else rather
|
||||
// than reinstalling under a live session or reporting a reset that only
|
||||
// ever reached another bundle.
|
||||
clearedBundleID string
|
||||
|
||||
screenWidth int
|
||||
screenHeight int
|
||||
|
||||
@@ -114,6 +138,10 @@ type Driver struct {
|
||||
// A seam so tests skip the simctl shell-outs.
|
||||
reinstallApp func(ctx context.Context) error
|
||||
|
||||
// terminateApp stops the app before its state is cleared. A seam so tests
|
||||
// skip the simctl shell-out.
|
||||
terminateApp func(ctx context.Context) error
|
||||
|
||||
// grantPaste pre-authorizes the app's pasteboard access. A seam so tests
|
||||
// skip the sqlite shell-out.
|
||||
grantPaste func(ctx context.Context) error
|
||||
@@ -136,15 +164,19 @@ type Driver struct {
|
||||
dialRunner func(address string) (transport.Companion, error)
|
||||
hybrid bool
|
||||
|
||||
// pickRunnerAddress hands every bring-up a free loopback port, on the
|
||||
// simulator and the device alike. One field, so no path can be wired
|
||||
// without it.
|
||||
pickRunnerAddress func() (string, error)
|
||||
|
||||
// Device-mode fields. On the physical-device path d.companion is the runner
|
||||
// dialed over a usbmux tunnel, hybrid is false, and runnerClient is nil.
|
||||
// coreDeviceID feeds devicectl; tunnel is the in-process usbmux forwarder
|
||||
// bridging the host loopback port to the runner's device-side port.
|
||||
deviceMode bool
|
||||
coreDeviceID string
|
||||
tunnel io.Closer
|
||||
startTunnel func(ctx context.Context, hardwareUDID, localAddress, devicePort string) (io.Closer, error)
|
||||
pickDeviceAddress func() (string, error)
|
||||
deviceMode bool
|
||||
coreDeviceID string
|
||||
tunnel io.Closer
|
||||
startTunnel func(ctx context.Context, hardwareUDID, localAddress, devicePort string) (io.Closer, error)
|
||||
|
||||
// processContext owns the companion child's lifetime: it is derived from
|
||||
// New's context (so a canceled run still reaps the child) and canceled by
|
||||
@@ -189,6 +221,9 @@ func New(ctx context.Context, options Options) (*Driver, error) {
|
||||
if options.UniqueDeviceIdentifier == "" {
|
||||
return nil, errors.New("ios companion: UniqueDeviceIdentifier is required")
|
||||
}
|
||||
if options.ClearState && options.BundleID == "" {
|
||||
return nil, errors.New("ios companion: clear-state needs BundleID: there is nothing to uninstall or wipe without it")
|
||||
}
|
||||
output := options.Output
|
||||
if output == nil {
|
||||
output = io.Discard
|
||||
@@ -206,6 +241,11 @@ func New(ctx context.Context, options Options) (*Driver, error) {
|
||||
doubleTapGapMilliseconds: gap,
|
||||
spawnChild: options.spawnChild,
|
||||
dial: options.dialCompanion,
|
||||
spawnRunner: options.spawnRunner,
|
||||
dialRunner: options.dialRunner,
|
||||
reinstallApp: options.reinstallApp,
|
||||
resetContainer: options.resetContainer,
|
||||
terminateApp: options.terminateApp,
|
||||
hybrid: hybridCompanionEnabled(),
|
||||
}
|
||||
if driverInstance.spawnChild == nil {
|
||||
@@ -232,9 +272,17 @@ func New(ctx context.Context, options Options) (*Driver, error) {
|
||||
return nil, err
|
||||
}
|
||||
driverInstance.address = address
|
||||
driverInstance.pickRunnerAddress = pickAddress
|
||||
driverInstance.restart = driverInstance.respawnAndRedial
|
||||
driverInstance.resetContainer = driverInstance.resetDataContainer
|
||||
driverInstance.reinstallApp = driverInstance.simctlReinstall
|
||||
if driverInstance.resetContainer == nil {
|
||||
driverInstance.resetContainer = driverInstance.resetDataContainer
|
||||
}
|
||||
if driverInstance.reinstallApp == nil {
|
||||
driverInstance.reinstallApp = driverInstance.simctlReinstall
|
||||
}
|
||||
if driverInstance.terminateApp == nil {
|
||||
driverInstance.terminateApp = driverInstance.simctlTerminate
|
||||
}
|
||||
driverInstance.grantPaste = driverInstance.grantPasteboardAccess
|
||||
driverInstance.processContext, driverInstance.processCancel = context.WithCancel(ctx)
|
||||
|
||||
@@ -245,6 +293,14 @@ func New(ctx context.Context, options Options) (*Driver, error) {
|
||||
}
|
||||
driverInstance.deviceLock = lock
|
||||
|
||||
if options.ClearState {
|
||||
if err := driverInstance.clearAppState(ctx); err != nil {
|
||||
driverInstance.Close()
|
||||
return nil, err
|
||||
}
|
||||
driverInstance.clearedBundleID = options.BundleID
|
||||
}
|
||||
|
||||
if err := driverInstance.bringUp(ctx); err != nil {
|
||||
driverInstance.Close()
|
||||
return nil, err
|
||||
@@ -358,7 +414,7 @@ func (d *Driver) bringUpRunner(ctx context.Context) error {
|
||||
// A fresh port every bring-up: after a restart the dying session's
|
||||
// listener may still answer on the old port and would satisfy the wait
|
||||
// below with a dead server.
|
||||
address, err := pickLoopbackAddress()
|
||||
address, err := d.pickRunnerAddress()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -441,6 +497,26 @@ func isConnectionError(err error) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// isBudgetExpiry reports whether err is a call that outlived its bound rather
|
||||
// than a failure the transport can name. Each transport says so differently:
|
||||
// the runner wraps the context's error when cancellation has landed and the
|
||||
// connection's i/o timeout when the deadline it armed from that context fires
|
||||
// first, and the legacy companion returns a gRPC status. Only an expiry earns
|
||||
// a session restart; an error the runner reports has already said what a fresh
|
||||
// session would say.
|
||||
func isBudgetExpiry(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, os.ErrDeadlineExceeded) {
|
||||
return true
|
||||
}
|
||||
if statusValue, ok := status.FromError(err); ok {
|
||||
return statusValue.Code() == codes.DeadlineExceeded
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (d *Driver) Launch(ctx context.Context, bundleID string, clearState bool, env map[string]string) error {
|
||||
if bundleID != "" {
|
||||
d.bundleID = bundleID
|
||||
@@ -452,6 +528,14 @@ func (d *Driver) Launch(ctx context.Context, bundleID string, clearState bool, e
|
||||
// loudly rather than silently dropping the request.
|
||||
return errors.New("ios companion: launch with environment variables is unsupported on this backend")
|
||||
}
|
||||
if clearState && (d.clearedBundleID == "" || d.clearedBundleID != d.bundleID) {
|
||||
// Clearing here would uninstall and reinstall the app underneath a live
|
||||
// automation session, which is what races FrontBoard's registration and
|
||||
// leaves the session launching a bundle FrontBoard has not registered.
|
||||
return fmt.Errorf("ios companion: clear-state must be requested when the driver is created (Options.ClearState) "+
|
||||
"for the bundle being launched; this backend cleared %q before its automation session existed, not %q",
|
||||
d.clearedBundleID, d.bundleID)
|
||||
}
|
||||
|
||||
// Terminate first so the launch is a clean cold start regardless of the
|
||||
// app's prior state. A not-running app is not an error here.
|
||||
@@ -459,12 +543,6 @@ func (d *Driver) Launch(ctx context.Context, bundleID string, clearState bool, e
|
||||
return companion.Terminate(callCtx, d.bundleID)
|
||||
})
|
||||
|
||||
if clearState {
|
||||
if err := d.clearAppState(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Grant the app pasteboard access before it runs so unicode input (which
|
||||
// must go through the pasteboard, since HID cannot express it) never trips
|
||||
// the iOS paste-permission prompt. clearState reinstall resets the grant,
|
||||
@@ -477,14 +555,55 @@ func (d *Driver) Launch(ctx context.Context, bundleID string, clearState bool, e
|
||||
}
|
||||
}
|
||||
|
||||
if err := d.lifecycleCall(ctx, func(callCtx context.Context, companion transport.Companion) error {
|
||||
return companion.Launch(callCtx, d.bundleID, true)
|
||||
}); err != nil {
|
||||
if err := d.launchWithSessionRecovery(ctx); err != nil {
|
||||
return fmt.Errorf("launch %s: %w", d.bundleID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// launchWithSessionRecovery runs the launch RPC and, when it blows its own
|
||||
// bound, replaces the session and launches again.
|
||||
//
|
||||
// A launch the simulator refuses, which is what a clear-state reinstall racing
|
||||
// FrontBoard's registration produces, never comes back as an error: XCTest
|
||||
// records the refusal as a test failure the runner cannot observe, then holds
|
||||
// the session's main thread for about four minutes walking a diagnostic chain
|
||||
// (a 120s accessibility wait, a spindump, an idle wait). So there is no error
|
||||
// text to key a retry on, only the expired bound, and every later call queues
|
||||
// behind the same wedge. Only a session that never served the refused launch
|
||||
// can serve the retry, which is why this restarts rather than calls again.
|
||||
func (d *Driver) launchWithSessionRecovery(ctx context.Context) error {
|
||||
launch := func(callCtx context.Context, companion transport.Companion) error {
|
||||
return companion.Launch(callCtx, d.bundleID, true)
|
||||
}
|
||||
err := d.lifecycleCall(ctx, launch)
|
||||
// A caller whose own budget ran out gets no restart: the bound that expired
|
||||
// was the caller's to spend, and the second attempt would inherit it dead.
|
||||
if !isBudgetExpiry(err) || ctx.Err() != nil || d.restart == nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(d.output, "launch %s blew its %v bound (%v); restarting the session and launching once more\n",
|
||||
d.bundleID, launchTimeout, err)
|
||||
|
||||
// The restart runs under the driver's own lifetime context for the same
|
||||
// reason withRecovery's does, while the second attempt stays on the
|
||||
// caller's. Both end at one deadline, so a launch that already spent
|
||||
// launchTimeout cannot then wait out a session cold start on top of it.
|
||||
recoveryDeadline := time.Now().Add(launchRecoveryTimeout)
|
||||
restartCtx := d.processContext
|
||||
if restartCtx == nil {
|
||||
restartCtx = ctx
|
||||
}
|
||||
restartCtx, cancelRestart := context.WithDeadline(restartCtx, recoveryDeadline)
|
||||
defer cancelRestart()
|
||||
if restartErr := d.restart(restartCtx); restartErr != nil {
|
||||
return fmt.Errorf("session restart failed: %w (original: %v)", restartErr, err)
|
||||
}
|
||||
relaunchCtx, cancelRelaunch := context.WithDeadline(ctx, recoveryDeadline)
|
||||
defer cancelRelaunch()
|
||||
return d.lifecycleCall(relaunchCtx, launch)
|
||||
}
|
||||
|
||||
// lifecycleCall runs an app lifecycle RPC against lifecycleCompanion under a
|
||||
// launchTimeout-bounded context, with the usual one-restart recovery. The
|
||||
// companion is resolved inside the retry so a restart's replacement client
|
||||
@@ -511,8 +630,14 @@ func (d *Driver) lifecycleCompanion() transport.Companion {
|
||||
|
||||
// clearAppState resets the app to a first-launch state. With an app path it
|
||||
// uninstalls and reinstalls; without one it falls back to wiping the app's data
|
||||
// container and warns once that a full reinstall needs the app path.
|
||||
// container and warns once that a full reinstall needs the app path. Called
|
||||
// only from construction, before any automation session is attached to the app.
|
||||
func (d *Driver) clearAppState(ctx context.Context) error {
|
||||
// Nothing may be writing to the state while it goes, which is the ordering
|
||||
// Launch used to hold: uninstall copes with a running app, deleting the
|
||||
// data container out from under one does not. Best effort, because an app
|
||||
// that is not running reports a failure that means nothing here.
|
||||
_ = d.terminateApp(ctx)
|
||||
if d.appPath != "" {
|
||||
if err := d.reinstallApp(ctx); err != nil {
|
||||
return fmt.Errorf("reinstall %s: %w", d.appPath, err)
|
||||
@@ -529,8 +654,14 @@ func (d *Driver) clearAppState(ctx context.Context) error {
|
||||
// simctlReinstall uninstalls and reinstalls the app bundle via simctl. App
|
||||
// lifecycle stays with simctl: the companion's install RPC misreads current
|
||||
// simulator targets' architectures and rejects valid bundles.
|
||||
// A failed uninstall ends the reinstall: `simctl install` over an installed app
|
||||
// carries its data container across, so clear-state would be reported without
|
||||
// happening. Uninstalling an app that is not installed exits 0, so there is no
|
||||
// benign failure here to sort out from a real one.
|
||||
func (d *Driver) simctlReinstall(ctx context.Context) error {
|
||||
_ = exec.CommandContext(ctx, "xcrun", "simctl", "uninstall", d.udid, d.bundleID).Run()
|
||||
if output, err := exec.CommandContext(ctx, "xcrun", "simctl", "uninstall", d.udid, d.bundleID).CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("simctl uninstall %s: %w: %s", d.bundleID, err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
output, err := exec.CommandContext(ctx, "xcrun", "simctl", "install", d.udid, d.appPath).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("simctl install: %w: %s", err, strings.TrimSpace(string(output)))
|
||||
@@ -538,6 +669,18 @@ func (d *Driver) simctlReinstall(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// simctlTerminate stops the app under test. Launch used to terminate through
|
||||
// the automation session before clearing; the clear now runs before any session
|
||||
// exists, so simctl is what is left to stop the app with. An app that is not
|
||||
// running reports a failure that means nothing to the caller, which is why
|
||||
// clearAppState treats this as best effort.
|
||||
func (d *Driver) simctlTerminate(ctx context.Context) error {
|
||||
if output, err := exec.CommandContext(ctx, "xcrun", "simctl", "terminate", d.udid, d.bundleID).CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("simctl terminate %s: %w: %s", d.bundleID, err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// grantPasteboardAccess authorizes the app to read the pasteboard without the
|
||||
// iOS permission prompt, by writing an allow row into the simulator's privacy
|
||||
// (TCC) database. This is the simulator counterpart to `simctl privacy grant`,
|
||||
@@ -965,7 +1108,10 @@ func (d *Driver) WaitForIdle(ctx context.Context, _ time.Duration) error {
|
||||
}
|
||||
|
||||
// RecentLogs returns no entries: the companion log RPC is a follow-up, so v1
|
||||
// reports an empty slice rather than failing.
|
||||
// reports an empty slice rather than failing. Every property reading state.logs
|
||||
// therefore holds vacuously on iOS and nothing says so; closing it means
|
||||
// tailing idb's streaming log RPC and mapping os_log levels onto the
|
||||
// single-letter scale driver.LogEntry declares.
|
||||
func (d *Driver) RecentLogs(_ context.Context, _ time.Time, _ string) ([]driver.LogEntry, error) {
|
||||
return []driver.LogEntry{}, nil
|
||||
}
|
||||
|
||||
@@ -18,10 +18,12 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
"github.com/priyanshujain/sanderling/internal/driver"
|
||||
"github.com/priyanshujain/sanderling/internal/driver/ioscompanion/companionpb"
|
||||
"github.com/priyanshujain/sanderling/internal/driver/ioscompanion/transport"
|
||||
)
|
||||
|
||||
@@ -183,47 +185,280 @@ func TestLaunchContinuesWhenGrantFails(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchClearStateReinstallsWithAppPath(t *testing.T) {
|
||||
// clearStateProbe records, in order, the calls a run makes to reset the app and
|
||||
// to bring the runner's automation session up. A reinstall recorded after the
|
||||
// session is the ordering that races FrontBoard.
|
||||
type clearStateProbe struct {
|
||||
mutex sync.Mutex
|
||||
events []string
|
||||
}
|
||||
|
||||
func (p *clearStateProbe) record(event string) {
|
||||
p.mutex.Lock()
|
||||
defer p.mutex.Unlock()
|
||||
p.events = append(p.events, event)
|
||||
}
|
||||
|
||||
func (p *clearStateProbe) recorded() []string {
|
||||
p.mutex.Lock()
|
||||
defer p.mutex.Unlock()
|
||||
out := make([]string, len(p.events))
|
||||
copy(out, p.events)
|
||||
return out
|
||||
}
|
||||
|
||||
// clearStateOptions wires every seam a hybrid bring-up needs, so New runs its
|
||||
// real sequence against fakes: no simulator, no simctl, no XCTest session.
|
||||
func clearStateOptions(t *testing.T, probe *clearStateProbe, udid string, clearState bool) Options {
|
||||
t.Helper()
|
||||
t.Setenv("SANDERLING_SIMULATOR_COMPANION", "")
|
||||
address := startLoopbackListener(t)
|
||||
return Options{
|
||||
UniqueDeviceIdentifier: udid,
|
||||
BundleID: "com.example.app",
|
||||
ClearState: clearState,
|
||||
Output: &bytes.Buffer{},
|
||||
pickAddress: func() (string, error) { return address, nil },
|
||||
spawnChild: func(context.Context, string) (*exec.Cmd, error) { return &exec.Cmd{}, nil },
|
||||
dialCompanion: func(string) (transport.Companion, error) {
|
||||
return &fakeCompanion{accessibilityJSON: "[]"}, nil
|
||||
},
|
||||
spawnRunner: func(context.Context, string) (*exec.Cmd, error) {
|
||||
probe.record("runner session")
|
||||
return &exec.Cmd{}, nil
|
||||
},
|
||||
dialRunner: func(string) (transport.Companion, error) {
|
||||
return &fakeCompanion{accessibilityJSON: "[]"}, nil
|
||||
},
|
||||
reinstallApp: func(context.Context) error { probe.record("reinstall"); return nil },
|
||||
resetContainer: func(context.Context) error { probe.record("reset container"); return nil },
|
||||
terminateApp: func(context.Context) error { probe.record("stop app"); return nil },
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearStateReinstallsOnceBeforeTheRunnerSession(t *testing.T) {
|
||||
probe := &clearStateProbe{}
|
||||
options := clearStateOptions(t, probe, "CLEAR-REINSTALL-UDID", true)
|
||||
options.AppPath = "/tmp/Sample.app"
|
||||
|
||||
d, err := New(context.Background(), options)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer d.Close()
|
||||
if err := d.Launch(context.Background(), "", true, nil); err != nil {
|
||||
t.Fatalf("Launch: %v", err)
|
||||
}
|
||||
|
||||
want := []string{"stop app", "reinstall", "runner session"}
|
||||
if got := probe.recorded(); !slices.Equal(got, want) {
|
||||
t.Fatalf("calls = %v, want %v: the reinstall must run once, on a stopped app, before the automation session attaches", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearStateWithoutAppPathWipesContainerBeforeTheRunnerSession(t *testing.T) {
|
||||
probe := &clearStateProbe{}
|
||||
output := &bytes.Buffer{}
|
||||
options := clearStateOptions(t, probe, "CLEAR-CONTAINER-UDID", true)
|
||||
options.Output = output
|
||||
|
||||
d, err := New(context.Background(), options)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer d.Close()
|
||||
if err := d.Launch(context.Background(), "", true, nil); err != nil {
|
||||
t.Fatalf("Launch: %v", err)
|
||||
}
|
||||
|
||||
want := []string{"stop app", "reset container", "runner session"}
|
||||
if got := probe.recorded(); !slices.Equal(got, want) {
|
||||
t.Fatalf("calls = %v, want %v: the fallback must wipe a stopped app's container once, before the session, and never reinstall", got, want)
|
||||
}
|
||||
if warnings := strings.Count(output.String(), "resetting the data container only"); warnings != 1 {
|
||||
t.Fatalf("warning emitted %d times, want once", warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithoutClearStateTheAppIsLeftAlone(t *testing.T) {
|
||||
probe := &clearStateProbe{}
|
||||
options := clearStateOptions(t, probe, "NO-CLEAR-UDID", false)
|
||||
options.AppPath = "/tmp/Sample.app"
|
||||
|
||||
d, err := New(context.Background(), options)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer d.Close()
|
||||
if err := d.Launch(context.Background(), "", false, nil); err != nil {
|
||||
t.Fatalf("Launch: %v", err)
|
||||
}
|
||||
|
||||
want := []string{"runner session"}
|
||||
if got := probe.recorded(); !slices.Equal(got, want) {
|
||||
t.Fatalf("calls = %v, want %v: a run that did not ask for clear state must not touch the install", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchRefusesClearStateTheDriverWasNotBuiltFor(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)
|
||||
|
||||
err := d.Launch(context.Background(), "", true, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "clear-state") {
|
||||
t.Fatalf("Launch err = %v, want a refusal naming clear-state", err)
|
||||
}
|
||||
if reinstalls != 1 {
|
||||
t.Fatalf("clear-state with app path must reinstall exactly once; got %d", reinstalls)
|
||||
if reinstalls != 0 {
|
||||
t.Fatalf("reinstalls = %d, want 0: a live session must never have the app reinstalled under it", reinstalls)
|
||||
}
|
||||
if indexOf(companion.calls, "launch") < indexOf(companion.calls, "terminate") {
|
||||
t.Fatalf("launch must still follow terminate; got %v", companion.calls)
|
||||
if indexOf(companion.recorded(), "launch") >= 0 {
|
||||
t.Fatalf("a refused launch must not reach the companion; got %v", companion.recorded())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchClearStateFallbackWarnsOnce(t *testing.T) {
|
||||
// TestLaunchRefusesClearStateForABundleItDidNotClear holds the guard to the
|
||||
// fact it is guarding. A driver built to clear one bundle has cleared nothing
|
||||
// for another, so reporting that launch as a clear-state launch is a reset the
|
||||
// caller was told happened and did not.
|
||||
func TestLaunchRefusesClearStateForABundleItDidNotClear(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 }
|
||||
d.clearedBundleID = "com.example.app"
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := d.Launch(context.Background(), "", true, nil); err != nil {
|
||||
t.Fatalf("Launch %d: %v", i, err)
|
||||
err := d.Launch(context.Background(), "com.other.app", true, nil)
|
||||
|
||||
if err == nil || !strings.Contains(err.Error(), "clear-state") {
|
||||
t.Fatalf("Launch err = %v, want a refusal naming clear-state: com.other.app was never cleared", err)
|
||||
}
|
||||
if indexOf(companion.recorded(), "launch") >= 0 {
|
||||
t.Fatalf("a launch reporting a clear that never happened must not reach the companion; got %v", companion.recorded())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRejectsClearStateWithoutBundleID(t *testing.T) {
|
||||
probe := &clearStateProbe{}
|
||||
options := clearStateOptions(t, probe, "NO-BUNDLE-UDID", true)
|
||||
options.BundleID = ""
|
||||
|
||||
if _, err := New(context.Background(), options); err == nil || !strings.Contains(err.Error(), "BundleID") {
|
||||
t.Fatalf("New err = %v, want a refusal naming BundleID", err)
|
||||
}
|
||||
if got := probe.recorded(); len(got) != 0 {
|
||||
t.Fatalf("calls = %v, want none: clearing an unnamed bundle would reinstall without resetting anything", got)
|
||||
}
|
||||
}
|
||||
|
||||
// scriptedXcrun puts an xcrun on PATH that logs each invocation's arguments and
|
||||
// answers from replies, a `case "$*" in` body, so a reinstall runs its real
|
||||
// command sequence and the log holds what reached the tool.
|
||||
func scriptedXcrun(t *testing.T, replies string) string {
|
||||
t.Helper()
|
||||
directory := t.TempDir()
|
||||
log := filepath.Join(directory, "xcrun.log")
|
||||
script := "#!/bin/sh\necho \"$*\" >> " + log + "\ncase \"$*\" in\n" + replies + "\nesac\n"
|
||||
if err := os.WriteFile(filepath.Join(directory, "xcrun"), []byte(script), 0o755); err != nil {
|
||||
t.Fatalf("write xcrun: %v", err)
|
||||
}
|
||||
t.Setenv("PATH", directory)
|
||||
return log
|
||||
}
|
||||
|
||||
func xcrunCalls(t *testing.T, log string) []string {
|
||||
t.Helper()
|
||||
contents, err := os.ReadFile(log)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
t.Fatalf("read %s: %v", log, err)
|
||||
}
|
||||
return strings.Split(strings.TrimSpace(string(contents)), "\n")
|
||||
}
|
||||
|
||||
func TestSimctlReinstallStopsWhenTheUninstallFails(t *testing.T) {
|
||||
log := scriptedXcrun(t, `"simctl uninstall "*) echo "Simulator device failed to uninstall app.example."; echo "Uninstall prohibited."; exit 22;;
|
||||
"simctl install "*) :;;`)
|
||||
d := &Driver{udid: "SIM-UDID", bundleID: "app.example", appPath: "/tmp/Sample.app"}
|
||||
|
||||
err := d.simctlReinstall(context.Background())
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("simctlReinstall reported success while app.example kept the data clear-state was asked to remove")
|
||||
}
|
||||
for _, want := range []string{"app.example", "Uninstall prohibited."} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("error %q does not quote %q", err, want)
|
||||
}
|
||||
}
|
||||
if resets != 2 {
|
||||
t.Fatalf("resetContainer called %d times, want 2", resets)
|
||||
if calls := xcrunCalls(t, log); slices.Contains(calls, "simctl install SIM-UDID /tmp/Sample.app") {
|
||||
t.Errorf("xcrun calls = %v: installing over the app carries its data into the run", calls)
|
||||
}
|
||||
warnings := strings.Count(output.String(), "resetting the data container only")
|
||||
if warnings != 1 {
|
||||
t.Fatalf("warning emitted %d times, want once", warnings)
|
||||
}
|
||||
|
||||
func TestSimctlReinstallProceedsWhenNothingIsInstalled(t *testing.T) {
|
||||
log := scriptedXcrun(t, `"simctl uninstall "*) :;;
|
||||
"simctl install "*) :;;`)
|
||||
d := &Driver{udid: "SIM-UDID", bundleID: "app.example", appPath: "/tmp/Sample.app"}
|
||||
|
||||
if err := d.simctlReinstall(context.Background()); err != nil {
|
||||
t.Fatalf("simctlReinstall: %v", err)
|
||||
}
|
||||
for _, call := range companion.calls {
|
||||
if call == "install" || call == "uninstall" {
|
||||
t.Fatalf("fallback path must not install/uninstall; got %v", companion.calls)
|
||||
}
|
||||
|
||||
want := []string{"simctl uninstall SIM-UDID app.example", "simctl install SIM-UDID /tmp/Sample.app"}
|
||||
if got := xcrunCalls(t, log); !slices.Equal(got, want) {
|
||||
t.Fatalf("xcrun calls = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClearStateStopsTheAppBeforeWipingItsContainer covers the ordering Launch
|
||||
// used to hold. simctl uninstall copes with a running app; deleting the data
|
||||
// container out from under one does not, and the CI iOS leg passes no app path
|
||||
// so it is the wipe that runs. A run whose previous run was interrupted finds
|
||||
// the app still up.
|
||||
func TestClearStateStopsTheAppBeforeWipingItsContainer(t *testing.T) {
|
||||
container := t.TempDir()
|
||||
stale := filepath.Join(container, "Documents")
|
||||
if err := os.Mkdir(stale, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
log := scriptedXcrun(t, `"simctl terminate "*) :;;
|
||||
"simctl get_app_container "*) echo `+container+`;;`)
|
||||
d := &Driver{udid: "SIM-UDID", bundleID: "app.example", output: &bytes.Buffer{}}
|
||||
d.terminateApp = d.simctlTerminate
|
||||
d.resetContainer = d.resetDataContainer
|
||||
|
||||
if err := d.clearAppState(context.Background()); err != nil {
|
||||
t.Fatalf("clearAppState: %v", err)
|
||||
}
|
||||
|
||||
want := []string{
|
||||
"simctl terminate SIM-UDID app.example",
|
||||
"simctl get_app_container SIM-UDID app.example data",
|
||||
}
|
||||
if got := xcrunCalls(t, log); !slices.Equal(got, want) {
|
||||
t.Fatalf("xcrun calls = %v, want %v: the app was still writing to the container being deleted", got, want)
|
||||
}
|
||||
if _, err := os.Stat(stale); !os.IsNotExist(err) {
|
||||
t.Fatalf("stat %s = %v, want the previous run's state gone", stale, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClearStateSurvivesAnAppThatIsNotRunning holds the terminate to best
|
||||
// effort. simctl exits non-zero when there is nothing to stop, and a first run
|
||||
// on a fresh simulator must not fail on it.
|
||||
func TestClearStateSurvivesAnAppThatIsNotRunning(t *testing.T) {
|
||||
container := t.TempDir()
|
||||
scriptedXcrun(t, `"simctl terminate "*) echo "No matching processes belonging to bundle identifier app.example"; exit 3;;
|
||||
"simctl get_app_container "*) echo `+container+`;;`)
|
||||
d := &Driver{udid: "SIM-UDID", bundleID: "app.example", output: &bytes.Buffer{}}
|
||||
d.terminateApp = d.simctlTerminate
|
||||
d.resetContainer = d.resetDataContainer
|
||||
|
||||
if err := d.clearAppState(context.Background()); err != nil {
|
||||
t.Fatalf("clearAppState: %v: an app that is not running is not a failure to clear", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -952,6 +1187,301 @@ func TestLaunchLeavesATighterCallerDeadlineAlone(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// blownBudgetShape is one way a transport the driver launches through reports
|
||||
// a lifecycle call outliving its budget.
|
||||
type blownBudgetShape struct {
|
||||
name string
|
||||
err error
|
||||
}
|
||||
|
||||
// blownBudgetShapes drives every such transport against a server that never
|
||||
// answers and returns the error each one really produces. The runner transport
|
||||
// has two: wrapTransport wraps the context's own error once cancellation has
|
||||
// landed, and the connection's i/o timeout when the deadline it armed from
|
||||
// that context fires first. The legacy transport reports the same expiry as a
|
||||
// gRPC status. Only the first satisfies errors.Is(err, context.DeadlineExceeded),
|
||||
// so a fake that returns ctx.Err() raw shows the driver a recovery that two
|
||||
// thirds of production can never reach.
|
||||
func blownBudgetShapes(t *testing.T) []blownBudgetShape {
|
||||
t.Helper()
|
||||
return []blownBudgetShape{
|
||||
{"runner context deadline", runnerContextDeadlineError(t)},
|
||||
{"runner connection deadline", silentRunnerLaunchError(t, connectionDeadlineOnly{time.Now().Add(100 * time.Millisecond)})},
|
||||
{"legacy grpc deadline", silentGRPCLaunchError(t)},
|
||||
}
|
||||
}
|
||||
|
||||
// runnerContextDeadlineError is the shape the runner transport produces once
|
||||
// the context's own cancellation has landed.
|
||||
func runnerContextDeadlineError(t *testing.T) error {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
|
||||
defer cancel()
|
||||
return silentRunnerLaunchError(t, ctx)
|
||||
}
|
||||
|
||||
// connectionDeadlineOnly carries a deadline the runner transport arms the
|
||||
// connection with, while its own cancellation never lands. That is the race
|
||||
// wrapTransport's second branch exists for: the connection's deadline fires
|
||||
// while ctx.Err() is still nil.
|
||||
type connectionDeadlineOnly struct{ deadline time.Time }
|
||||
|
||||
func (c connectionDeadlineOnly) Deadline() (time.Time, bool) { return c.deadline, true }
|
||||
func (c connectionDeadlineOnly) Done() <-chan struct{} { return nil }
|
||||
func (c connectionDeadlineOnly) Err() error { return nil }
|
||||
func (c connectionDeadlineOnly) Value(any) any { return nil }
|
||||
|
||||
// silentRunnerLaunchError returns what the real runner transport produces for a
|
||||
// launch nobody ever answers.
|
||||
func silentRunnerLaunchError(t *testing.T, ctx context.Context) error {
|
||||
t.Helper()
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
closed := make(chan struct{})
|
||||
go func() {
|
||||
conn, acceptErr := listener.Accept()
|
||||
if acceptErr != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
<-closed
|
||||
}()
|
||||
companion, err := transport.DialRunner(listener.Addr().String(), "SIM-UDID", "com.example.app")
|
||||
if err != nil {
|
||||
listener.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
close(closed)
|
||||
_ = companion.Close()
|
||||
listener.Close()
|
||||
})
|
||||
|
||||
launchErr := companion.Launch(ctx, "com.example.app", true)
|
||||
if launchErr == nil {
|
||||
t.Fatal("the runner transport reported a launch no server ever answered")
|
||||
}
|
||||
return launchErr
|
||||
}
|
||||
|
||||
// silentCompanionServer is the legacy companion with a launch that never
|
||||
// answers, so the caller's own deadline is what ends the call.
|
||||
type silentCompanionServer struct {
|
||||
companionpb.UnimplementedCompanionServiceServer
|
||||
}
|
||||
|
||||
func (silentCompanionServer) Launch(stream grpc.BidiStreamingServer[companionpb.LaunchRequest, companionpb.LaunchResponse]) error {
|
||||
<-stream.Context().Done()
|
||||
return stream.Context().Err()
|
||||
}
|
||||
|
||||
// silentGRPCLaunchError returns what the legacy transport produces for the same
|
||||
// launch, which SANDERLING_SIMULATOR_COMPANION=legacy still runs on.
|
||||
func silentGRPCLaunchError(t *testing.T) error {
|
||||
t.Helper()
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := grpc.NewServer()
|
||||
companionpb.RegisterCompanionServiceServer(server, silentCompanionServer{})
|
||||
go server.Serve(listener)
|
||||
t.Cleanup(server.Stop)
|
||||
|
||||
companion, err := transport.Dial(listener.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { companion.Close() })
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 250*time.Millisecond)
|
||||
defer cancel()
|
||||
launchErr := companion.Launch(ctx, "com.example.app", true)
|
||||
if launchErr == nil {
|
||||
t.Fatal("the legacy transport reported a launch the companion never answered")
|
||||
}
|
||||
return launchErr
|
||||
}
|
||||
|
||||
// wedgedUntilRestartCompanion models the session a refused launch leaves
|
||||
// behind: the refusal is never reported, no later launch is answered until the
|
||||
// session itself is replaced, and the expired bound reaches the driver in
|
||||
// whatever shape its transport gives it.
|
||||
type wedgedUntilRestartCompanion struct {
|
||||
fakeCompanion
|
||||
blownBudget error
|
||||
mutex sync.Mutex
|
||||
replaced bool
|
||||
attempted int
|
||||
}
|
||||
|
||||
func (w *wedgedUntilRestartCompanion) replaceSession() {
|
||||
w.mutex.Lock()
|
||||
defer w.mutex.Unlock()
|
||||
w.replaced = true
|
||||
}
|
||||
|
||||
func (w *wedgedUntilRestartCompanion) launchAttempts() int {
|
||||
w.mutex.Lock()
|
||||
defer w.mutex.Unlock()
|
||||
return w.attempted
|
||||
}
|
||||
|
||||
func (w *wedgedUntilRestartCompanion) Launch(ctx context.Context, _ string, _ bool) error {
|
||||
w.mutex.Lock()
|
||||
w.attempted++
|
||||
replaced := w.replaced
|
||||
w.mutex.Unlock()
|
||||
if replaced {
|
||||
return nil
|
||||
}
|
||||
<-ctx.Done()
|
||||
return w.blownBudget
|
||||
}
|
||||
|
||||
// TestLaunchReplacesTheSessionAfterALaunchBlowsItsBound covers the FrontBoard
|
||||
// race: a clear-state reinstall the simulator has not finished registering
|
||||
// makes the session refuse the launch, and XCTest answers that refusal with
|
||||
// minutes of diagnostics instead of an error, so the bound expires and every
|
||||
// later call queues behind the same wedge. Calling launch again on that session
|
||||
// cannot work; the run only recovers if the session is replaced first. The
|
||||
// recovery has to fire on every shape the driver's transports report that
|
||||
// expiry in, because which one arrives is a race the driver does not control.
|
||||
func TestLaunchReplacesTheSessionAfterALaunchBlowsItsBound(t *testing.T) {
|
||||
for _, shape := range blownBudgetShapes(t) {
|
||||
t.Run(shape.name, func(t *testing.T) {
|
||||
previous := launchTimeout
|
||||
launchTimeout = 100 * time.Millisecond
|
||||
defer func() { launchTimeout = previous }()
|
||||
|
||||
companion := &wedgedUntilRestartCompanion{blownBudget: shape.err}
|
||||
output := &bytes.Buffer{}
|
||||
d := newTestDriver(companion)
|
||||
d.output = output
|
||||
restarts := 0
|
||||
d.restart = func(context.Context) error {
|
||||
restarts++
|
||||
companion.replaceSession()
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := d.Launch(context.Background(), "", false, nil); err != nil {
|
||||
t.Fatalf("Launch: %v", err)
|
||||
}
|
||||
if restarts != 1 {
|
||||
t.Fatalf("session restarts = %d, want exactly 1 (the session reported %v)", restarts, shape.err)
|
||||
}
|
||||
if attempts := companion.launchAttempts(); attempts != 2 {
|
||||
t.Fatalf("launch attempts = %d, want 2: one that wedged and one on the replaced session", attempts)
|
||||
}
|
||||
if !strings.Contains(output.String(), "restarting the session") {
|
||||
t.Fatalf("the recovery was silent, so a run that needed it never says so; output was %q", output.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLaunchBoundsTheSessionRestartItTriggers keeps the recovery inside a
|
||||
// budget of its own. The restart deliberately runs on the driver's lifetime
|
||||
// context rather than the caller's, so without a deadline a session that never
|
||||
// comes back would hang the launch path exactly the way #73 stopped it hanging.
|
||||
func TestLaunchBoundsTheSessionRestartItTriggers(t *testing.T) {
|
||||
previousLaunch, previousRecovery := launchTimeout, launchRecoveryTimeout
|
||||
launchTimeout = 100 * time.Millisecond
|
||||
launchRecoveryTimeout = 200 * time.Millisecond
|
||||
defer func() { launchTimeout, launchRecoveryTimeout = previousLaunch, previousRecovery }()
|
||||
|
||||
d := newTestDriver(&wedgedUntilRestartCompanion{blownBudget: runnerContextDeadlineError(t)})
|
||||
d.restart = func(restartCtx context.Context) error {
|
||||
<-restartCtx.Done()
|
||||
return restartCtx.Err()
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- d.Launch(context.Background(), "", false, nil) }()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err == nil || !strings.Contains(err.Error(), "session restart failed") {
|
||||
t.Fatalf("err = %v, want the failed restart named", err)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("Launch never returned: a session that never comes back hangs the launch path")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLaunchKeepsTheSessionWhenTheCallersOwnDeadlineExpires holds the recovery
|
||||
// to the driver's own bound. Spending a session restart on a caller that has
|
||||
// already run out of budget cannot produce a launch, only a later failure.
|
||||
func TestLaunchKeepsTheSessionWhenTheCallersOwnDeadlineExpires(t *testing.T) {
|
||||
previous := launchTimeout
|
||||
launchTimeout = 30 * time.Second
|
||||
defer func() { launchTimeout = previous }()
|
||||
|
||||
companion := &wedgedUntilRestartCompanion{blownBudget: runnerContextDeadlineError(t)}
|
||||
d := newTestDriver(companion)
|
||||
restarts := 0
|
||||
d.restart = func(context.Context) error {
|
||||
restarts++
|
||||
companion.replaceSession()
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
if err := d.Launch(ctx, "", false, nil); !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("err = %v, want a deadline-exceeded error", err)
|
||||
}
|
||||
if restarts != 0 {
|
||||
t.Fatalf("session restarts = %d, want 0", restarts)
|
||||
}
|
||||
}
|
||||
|
||||
// refusedLaunchCompanion answers a launch the way the runner does once it
|
||||
// checks the app's state after activating it: promptly, naming the app and the
|
||||
// state it reached, over a session that is still serving.
|
||||
type refusedLaunchCompanion struct {
|
||||
fakeCompanion
|
||||
attempts int
|
||||
}
|
||||
|
||||
func (r *refusedLaunchCompanion) Launch(context.Context, string, bool) error {
|
||||
r.attempts++
|
||||
return errors.New(`runner launch: failed("com.example.app is not running after launch")`)
|
||||
}
|
||||
|
||||
// TestLaunchKeepsTheSessionWhenTheRunnerNamesTheRefusal separates a launch that
|
||||
// answers from a launch that never does. The session restart is the only
|
||||
// recovery from a wedged session, and it costs a cold start; a runner that
|
||||
// reports the app's state has already said what a fresh session would say, so
|
||||
// restarting to hear it again only delays the error and hides the app under it.
|
||||
func TestLaunchKeepsTheSessionWhenTheRunnerNamesTheRefusal(t *testing.T) {
|
||||
companion := &refusedLaunchCompanion{}
|
||||
output := &bytes.Buffer{}
|
||||
d := newTestDriver(companion)
|
||||
d.output = output
|
||||
restarts := 0
|
||||
d.restart = func(context.Context) error {
|
||||
restarts++
|
||||
return nil
|
||||
}
|
||||
|
||||
err := d.Launch(context.Background(), "", false, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "com.example.app is not running after launch") {
|
||||
t.Fatalf("err = %v, want the runner's refusal reaching the caller intact", err)
|
||||
}
|
||||
if restarts != 0 {
|
||||
t.Fatalf("session restarts = %d, want 0: a refusal the runner reported is not a wedged session", restarts)
|
||||
}
|
||||
if companion.attempts != 1 {
|
||||
t.Fatalf("launch attempts = %d, want 1: relaunching an app the runner just refused cannot launch it", companion.attempts)
|
||||
}
|
||||
if strings.Contains(output.String(), "restarting the session") {
|
||||
t.Fatalf("the driver announced a recovery it must not spend here; output was %q", output.String())
|
||||
}
|
||||
}
|
||||
|
||||
// newLockTestOptions builds New options that dial a seamed companion, so the
|
||||
// device-lock tests exercise New without spawning anything.
|
||||
func newLockTestOptions(t *testing.T, udid string) Options {
|
||||
|
||||
Reference in new issue
Block a user