From 1de179995bbb99f66ac3a993fe06e7be380b2d5f Mon Sep 17 00:00:00 2001 From: PJ Date: Sat, 22 Aug 2026 21:16:24 +0530 Subject: [PATCH] fix(ios): three unbacked reads report not-supported instead of success RecentLogs returned an empty slice, Metrics a zeroed sample and Health a hardcoded Ready: true, which are the same values a working read returns. Every property over state.logs held vacuously on iOS with nothing saying the logs were never read. driver.ErrNotSupported makes the silence representable. --- internal/driver/driver.go | 20 +++++++++++-- internal/driver/ioscompanion/driver.go | 19 +++++++----- internal/driver/ioscompanion/driver_test.go | 33 +++++++++++++++++---- 3 files changed, 56 insertions(+), 16 deletions(-) diff --git a/internal/driver/driver.go b/internal/driver/driver.go index 8d4acd6..f109b27 100644 --- a/internal/driver/driver.go +++ b/internal/driver/driver.go @@ -21,6 +21,14 @@ var ErrGestureUndelivered = errors.New("gesture reached no element") // ErrGestureUndelivered and the runner records it as an unresolved selector. var ErrSelectorMatchedNothing = errors.New("selector matched no element") +// ErrNotSupported reports a contract method the driver has no way to answer on +// its platform. It is neither a device fault nor an observation: a caller that +// reads the zero value as one records a reading it never took, which is what +// lets a property over a channel the driver never opened hold vacuously with +// nothing in the run's output saying so. Callers check it with errors.Is and +// record that the read did not happen. +var ErrNotSupported = errors.New("driver: capability not supported") + // DeviceDriver abstracts the platform-specific UI automation backend. v0.1 // surface matches proto/driverpb/driver.proto. The sidecar implementation // lives under driver/sidecar; the web implementation under driver/chrome; @@ -50,15 +58,23 @@ type DeviceDriver interface { // independent reads can land on different frames during transitions. Snapshot(ctx context.Context) (string, Image, error) // RecentLogs returns log entries at or after `since`, filtered to - // `minLevel` or above. An empty minLevel defaults to "E". + // `minLevel` or above. An empty minLevel defaults to "E". A driver with + // no log source returns ErrNotSupported rather than an empty slice: the + // two are the same answer to a spec reading state.logs, and only one of + // them means the app logged nothing. RecentLogs(ctx context.Context, since time.Time, minLevel string) ([]LogEntry, error) WaitForIdle(ctx context.Context, duration time.Duration) error + // Health reports whether the backend is attached and serving. A driver + // that runs no readiness check returns ErrNotSupported; Ready is a + // verdict, so reporting it true without one tells the caller a check + // passed that never ran. Health(ctx context.Context) (Health, error) // Metrics samples the app's CPU and memory at the time of the call. // CPUPercent is percent of a single core (multi-core apps can exceed // 100). HeapBytes is resident set size; TotalMemoryBytes includes - // native allocations. + // native allocations. A driver that cannot sample returns + // ErrNotSupported rather than a zeroed sample. Metrics(ctx context.Context, bundleID string) (Metrics, error) } diff --git a/internal/driver/ioscompanion/driver.go b/internal/driver/ioscompanion/driver.go index e186434..c3ac18f 100644 --- a/internal/driver/ioscompanion/driver.go +++ b/internal/driver/ioscompanion/driver.go @@ -1107,21 +1107,24 @@ func (d *Driver) WaitForIdle(ctx context.Context, _ time.Duration) error { return nil } -// RecentLogs returns no entries: the companion log RPC is a follow-up, so v1 -// 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. +// RecentLogs, Metrics and Health have no companion RPC behind them yet, so +// each reports driver.ErrNotSupported. An empty log slice, a zeroed sample and +// a hardcoded Ready are the same values a working read can return, so answering +// with them told the runner three checks had passed that never ran and left +// every property reading state.logs holding vacuously with nothing saying so. +// Closing them means tailing the streaming log RPC and mapping os_log levels +// onto the single-letter scale driver.LogEntry declares, sampling the app +// process, and probing the bridge for readiness. func (d *Driver) RecentLogs(_ context.Context, _ time.Time, _ string) ([]driver.LogEntry, error) { - return []driver.LogEntry{}, nil + return nil, driver.ErrNotSupported } func (d *Driver) Metrics(_ context.Context, _ string) (driver.Metrics, error) { - return driver.Metrics{}, nil + return driver.Metrics{}, driver.ErrNotSupported } func (d *Driver) Health(_ context.Context) (driver.Health, error) { - return driver.Health{Ready: true, Platform: "ios"}, nil + return driver.Health{}, driver.ErrNotSupported } // ForegroundApp reports the foreground app. It returns the app under test when diff --git a/internal/driver/ioscompanion/driver_test.go b/internal/driver/ioscompanion/driver_test.go index b6d13dd..314fac2 100644 --- a/internal/driver/ioscompanion/driver_test.go +++ b/internal/driver/ioscompanion/driver_test.go @@ -699,14 +699,35 @@ func TestForegroundAppFallsBackToOtherRunningUserApp(t *testing.T) { } } -func TestHealthReportsIOS(t *testing.T) { +// The three reads the companion has no RPC for must say so. Answering an empty +// log slice, a zeroed sample or Ready reports three checks as having passed +// that never ran, and a spec's log properties then hold on evidence nobody +// collected. +func TestUnbackedReadsReportNotSupported(t *testing.T) { d := newTestDriver(&fakeCompanion{}) - health, err := d.Health(context.Background()) - if err != nil { - t.Fatalf("Health: %v", err) + + logs, err := d.RecentLogs(context.Background(), time.Time{}, "E") + if !errors.Is(err, driver.ErrNotSupported) { + t.Errorf("RecentLogs err = %v, want driver.ErrNotSupported", err) } - if !health.Ready || health.Platform != "ios" { - t.Fatalf("Health = %+v, want ready ios", health) + if len(logs) != 0 { + t.Errorf("RecentLogs returned %d entries alongside its error", len(logs)) + } + + metrics, err := d.Metrics(context.Background(), "com.fixture") + if !errors.Is(err, driver.ErrNotSupported) { + t.Errorf("Metrics err = %v, want driver.ErrNotSupported", err) + } + if metrics != (driver.Metrics{}) { + t.Errorf("Metrics = %+v, want the zero sample", metrics) + } + + health, err := d.Health(context.Background()) + if !errors.Is(err, driver.ErrNotSupported) { + t.Errorf("Health err = %v, want driver.ErrNotSupported", err) + } + if health.Ready { + t.Errorf("Health = %+v: the driver reports a readiness check it never ran", health) } }