mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
* feat(hierarchy): name the route a native tree shows The screen name was web-only: the Chrome driver stamps sanderling-screen on the root and nothing else does, so every Android and iOS step recorded and logged an empty screen. The route marker the tree already carries (the resource id ending in Screen, the same one Transitional counts) names it. * feat(runner): say what each step did in the step log One line per step carried only an index and a node count. It now names the screen, the action, its target and the typed value, the last through the same redaction the trace and the prompt use. Emitted after the apply so the line reports what actually happened, skip reason included. * fix(sidecar): state on android whether a field is a secure entry maestro's tree mapper copies a fixed attribute list off the device's XML and password is not on it, so no android element ever reported the fact and the conservative rule downstream redacted every typed value in the trace, the prompt and the log. The XML still carries it: re-read it once per settled snapshot and state the fact on the text fields it matches. A field it cannot match stays unstated, which still reads as a credential. * docs: correct the record that android never reports a secure field Four places said android reports the fact for nothing and that every typed value there is redacted. The sidecar now states it, so they described the old behaviour. * test(sidecar): fail the build if maestro renames the call the fact comes from * fix(sidecar): state the fact on a field named by its hint alone collectTextFields matched on class only, so a node the go side calls editable off its hintText was left unstated and its typed value redacted. * docs: record that ios and web state secure:false for compose password fields Both derive the fact from a widget type a compose app never has, so the value reaches the trace in the clear. Verified on folio on both targets. * feat(android): read the application id out of an apk parses the compiled AndroidManifest.xml rather than shelling out to aapt2, which lives in the versioned build-tools directory that hosts with only platform-tools never install. Claude-Session: https://claude.ai/code/session_012PVErdr3ZzyUASeVQDWsUc * feat(cli): let --android-app-path supply the bundle id --bundle-id stays required everywhere else, and an explicit one still wins, so the apk can never quietly override what was asked for. Claude-Session: https://claude.ai/code/session_012PVErdr3ZzyUASeVQDWsUc * docs: record that the apk can name the package itself Claude-Session: https://claude.ai/code/session_012PVErdr3ZzyUASeVQDWsUc * fix(testrun): pass the jvm the flag that silences the jdk 24 unsafe warning * feat(folio): ask which android device to run on when none is named * feat(folio): pin ios recipes to one simulator udid and ask when several match * docs(ci): say how just ios lands on the simulator the boot step chose * chore(folio): ignore run output anywhere under examples/folio * refactor(hierarchy): name no screen for a tree Transitional calls a cross-fade ScreenName kept its own reading of the route markers and disagreed with Transitional on a marker repeated by a nested node: it named the screen on a step the runner was skipping as unsettled. One reading now. * refactor(sidecar): inline the one attempt passed to callViewHierarchy A named constant and its own comment for a literal used once. * test(sidecar): compare the whole tree when checking the annotation changes nothing else The old assertions checked one id string and one bounds value, and passed with every other attribute stripped off every node. Now the annotated tree minus the two facts it stated must equal the input. * fix(sidecar): match a field to its xml node by class as well as id and bounds A wrapper drawn to the same bounds as the untagged field inside it shared the field's key, both were dropped as ambiguous, and every value typed into an untagged field was redacted. The class tells them apart. * docs(runs): record what the android hierarchy re-read costs per step Two 1m runs per binary on folio, same seed, before and after the re-read.
349 lines
11 KiB
Go
349 lines
11 KiB
Go
package testrun
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/priyanshujain/sanderling/internal/driver"
|
|
"github.com/priyanshujain/sanderling/internal/driver/ioscompanion"
|
|
"github.com/priyanshujain/sanderling/internal/ios"
|
|
)
|
|
|
|
// stubDeviceDriver is a no-op DeviceDriver so routing tests never build or spawn
|
|
// a real runner. Only construction wiring is under test.
|
|
type stubDeviceDriver struct{ driver.DeviceDriver }
|
|
|
|
func TestBuildDriverRoutesPhysicalIOSToDeviceDriver(t *testing.T) {
|
|
stubPreflight(t)
|
|
original := newDeviceDriver
|
|
t.Cleanup(func() { newDeviceDriver = original })
|
|
|
|
var got ioscompanion.DeviceOptions
|
|
closed := false
|
|
newDeviceDriver = func(_ context.Context, options ioscompanion.DeviceOptions) (driver.DeviceDriver, func(), error) {
|
|
got = options
|
|
return stubDeviceDriver{}, func() { closed = true }, nil
|
|
}
|
|
|
|
options := Options{Platform: "ios", BundleID: "app.folio", IosAppPath: "/tmp/iosApp.app", ClearData: true}
|
|
options.iosIsSimulator = false
|
|
options.iosUDID = "00008140-HW"
|
|
options.iosCoreDeviceID = "CORE-1"
|
|
|
|
d, cleanup, err := buildDriver(context.Background(), options, io.Discard)
|
|
if err != nil {
|
|
t.Fatalf("buildDriver: %v", err)
|
|
}
|
|
if d == nil {
|
|
t.Fatal("expected a device driver")
|
|
}
|
|
if got.HardwareUDID != "00008140-HW" || got.CoreDeviceID != "CORE-1" {
|
|
t.Fatalf("DeviceOptions ids = %+v, want the resolved hardware/core ids", got)
|
|
}
|
|
if got.BundleID != "app.folio" || got.AppPath != "/tmp/iosApp.app" {
|
|
t.Fatalf("DeviceOptions = %+v, want bundle and app path threaded through", got)
|
|
}
|
|
if !got.ClearState {
|
|
t.Fatalf("DeviceOptions = %+v, want clear-data threaded through: the driver clears before its session, so a launch cannot", got)
|
|
}
|
|
cleanup()
|
|
if !closed {
|
|
t.Fatal("cleanup must close the device driver")
|
|
}
|
|
}
|
|
|
|
func TestBuildDriverThreadsClearStateToTheSimulatorDriver(t *testing.T) {
|
|
stubPreflight(t)
|
|
original := newSimulatorDriver
|
|
t.Cleanup(func() { newSimulatorDriver = original })
|
|
|
|
var got ioscompanion.Options
|
|
newSimulatorDriver = func(_ context.Context, options ioscompanion.Options) (driver.DeviceDriver, func(), error) {
|
|
got = options
|
|
return stubDeviceDriver{}, func() {}, nil
|
|
}
|
|
|
|
options := Options{Platform: "ios", BundleID: "app.folio", IosAppPath: "/tmp/iosApp.app", ClearData: true}
|
|
options.iosIsSimulator = true
|
|
options.iosUDID = "SIM-UDID"
|
|
|
|
if _, _, err := buildDriver(context.Background(), options, io.Discard); err != nil {
|
|
t.Fatalf("buildDriver: %v", err)
|
|
}
|
|
if got.UniqueDeviceIdentifier != "SIM-UDID" || got.BundleID != "app.folio" || got.AppPath != "/tmp/iosApp.app" {
|
|
t.Fatalf("Options = %+v, want the resolved target, bundle and app path", got)
|
|
}
|
|
if !got.ClearState {
|
|
t.Fatalf("Options = %+v, want clear-data threaded through: the driver clears before its session, so a launch cannot", got)
|
|
}
|
|
}
|
|
|
|
func TestBuildDriverSurfacesDeviceConstructionError(t *testing.T) {
|
|
stubPreflight(t)
|
|
original := newDeviceDriver
|
|
t.Cleanup(func() { newDeviceDriver = original })
|
|
newDeviceDriver = func(context.Context, ioscompanion.DeviceOptions) (driver.DeviceDriver, func(), error) {
|
|
return nil, nil, errors.New("no signing creds")
|
|
}
|
|
options := Options{Platform: "ios"}
|
|
options.iosIsSimulator = false
|
|
if _, _, err := buildDriver(context.Background(), options, io.Discard); err == nil {
|
|
t.Fatal("expected the device construction error to surface")
|
|
}
|
|
}
|
|
|
|
// A sidecar that dies during startup leaves the health poll with nothing to
|
|
// talk to, and reporting that as a deadline sends the reader after a gRPC
|
|
// timeout instead of the exit that already happened.
|
|
func TestAwaitSidecarReportsTheExitItSaw(t *testing.T) {
|
|
exited := make(chan error, 1)
|
|
exited <- errors.New("exit status 1")
|
|
close(exited)
|
|
|
|
err := awaitSidecar(
|
|
context.Background(),
|
|
"127.0.0.1:54321",
|
|
30*time.Second,
|
|
func(ctx context.Context) error { <-ctx.Done(); return ctx.Err() },
|
|
exited,
|
|
)
|
|
if err == nil {
|
|
t.Fatal("expected an error when the sidecar exits before it is healthy")
|
|
}
|
|
for _, want := range []string{
|
|
"sidecar exited before it answered a health check on 127.0.0.1:54321: exit status 1",
|
|
"check the sidecar output above",
|
|
"sanderling doctor --platform=android",
|
|
} {
|
|
if !strings.Contains(err.Error(), want) {
|
|
t.Errorf("error %q missing %q", err, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAwaitSidecarTimeoutSaysOnlyWhatItObserved(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
|
defer cancel()
|
|
|
|
err := awaitSidecar(
|
|
ctx,
|
|
"127.0.0.1:54321",
|
|
50*time.Millisecond,
|
|
func(ctx context.Context) error { <-ctx.Done(); return ctx.Err() },
|
|
make(chan error, 1),
|
|
)
|
|
if err == nil {
|
|
t.Fatal("expected an error when the sidecar never answers")
|
|
}
|
|
for _, want := range []string{
|
|
"sidecar did not answer a health check on 127.0.0.1:54321 within 50ms and is still running",
|
|
"check the sidecar output above",
|
|
"sanderling doctor --platform=android",
|
|
} {
|
|
if !strings.Contains(err.Error(), want) {
|
|
t.Errorf("error %q missing %q", err, want)
|
|
}
|
|
}
|
|
if strings.Contains(err.Error(), "context deadline exceeded") {
|
|
t.Errorf("error %q must not hand the reader a bare gRPC deadline", err)
|
|
}
|
|
}
|
|
|
|
func TestAwaitSidecarHealthyReturnsNil(t *testing.T) {
|
|
err := awaitSidecar(
|
|
context.Background(),
|
|
"127.0.0.1:54321",
|
|
30*time.Second,
|
|
func(context.Context) error { return nil },
|
|
make(chan error, 1),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("expected a healthy sidecar to pass, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestStopSidecarTerminatesARunningSidecar(t *testing.T) {
|
|
command := exec.Command("sleep", "60")
|
|
if err := command.Start(); err != nil {
|
|
t.Fatalf("start: %v", err)
|
|
}
|
|
exited := watchSidecar(command)
|
|
|
|
stopped := make(chan struct{})
|
|
go func() {
|
|
stopSidecar(command, exited)
|
|
close(stopped)
|
|
}()
|
|
select {
|
|
case <-stopped:
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatal("stopSidecar never returned for a running sidecar")
|
|
}
|
|
if got := command.ProcessState.String(); got != "signal: terminated" {
|
|
t.Errorf("sidecar ended as %q, want the SIGTERM its shutdown hook needs", got)
|
|
}
|
|
}
|
|
|
|
// The startup path takes the exit status to report it, so the shutdown path
|
|
// that follows must not sit waiting for a status nobody will send again.
|
|
func TestStopSidecarAfterTheStartupPathTookTheExitStatus(t *testing.T) {
|
|
command := exec.Command("sh", "-c", "exit 3")
|
|
if err := command.Start(); err != nil {
|
|
t.Fatalf("start: %v", err)
|
|
}
|
|
exited := watchSidecar(command)
|
|
|
|
err := awaitSidecar(
|
|
context.Background(),
|
|
"127.0.0.1:54321",
|
|
30*time.Second,
|
|
func(ctx context.Context) error { <-ctx.Done(); return ctx.Err() },
|
|
exited,
|
|
)
|
|
if err == nil || !strings.Contains(err.Error(), "exit status 3") {
|
|
t.Fatalf("expected the sidecar's real exit status, got %v", err)
|
|
}
|
|
|
|
stopped := make(chan struct{})
|
|
go func() {
|
|
stopSidecar(command, exited)
|
|
close(stopped)
|
|
}()
|
|
select {
|
|
case <-stopped:
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatal("stopSidecar blocked on an exit status the startup path had already taken")
|
|
}
|
|
}
|
|
|
|
// stubPreflight bypasses the host-readiness checks so routing tests exercise
|
|
// driver construction on a Linux CI runner that lacks xcrun/java.
|
|
func stubPreflight(t *testing.T) {
|
|
t.Helper()
|
|
original := preflight
|
|
t.Cleanup(func() { preflight = original })
|
|
preflight = func(context.Context, string) error { return nil }
|
|
}
|
|
|
|
func swapIOSResolveSeams(t *testing.T) {
|
|
t.Helper()
|
|
origTarget, origDevice, origEnsure := iosResolveTarget, iosResolveDevice, iosEnsureSimulator
|
|
t.Cleanup(func() {
|
|
iosResolveTarget, iosResolveDevice, iosEnsureSimulator = origTarget, origDevice, origEnsure
|
|
})
|
|
}
|
|
|
|
func TestResolveIOSTargetPhysicalDeviceFillsIDs(t *testing.T) {
|
|
swapIOSResolveSeams(t)
|
|
iosResolveTarget = func(_ context.Context, query string) (string, bool, error) {
|
|
return query, false, nil
|
|
}
|
|
resolveDeviceCalled := ""
|
|
iosResolveDevice = func(_ context.Context, query string) (ios.Device, error) {
|
|
resolveDeviceCalled = query
|
|
return ios.Device{Name: "iPhone", HardwareUDID: "00008140-HW", CoreDeviceID: "CORE-1"}, nil
|
|
}
|
|
iosEnsureSimulator = func(context.Context, string, io.Writer) error {
|
|
t.Fatal("must not boot a simulator on the device path")
|
|
return nil
|
|
}
|
|
|
|
options := Options{Platform: "ios", IosDevice: "iPhone"}
|
|
resolved, err := resolveIOSTarget(context.Background(), options, io.Discard)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if resolveDeviceCalled != "iPhone" {
|
|
t.Fatalf("ResolveDevice query = %q, want iPhone", resolveDeviceCalled)
|
|
}
|
|
if resolved.iosIsSimulator {
|
|
t.Fatal("device target must not be marked a simulator")
|
|
}
|
|
if resolved.iosUDID != "00008140-HW" || resolved.iosCoreDeviceID != "CORE-1" {
|
|
t.Fatalf("resolved ids = (%q, %q), want hardware/core ids", resolved.iosUDID, resolved.iosCoreDeviceID)
|
|
}
|
|
}
|
|
|
|
func TestResolveIOSTargetSimulatorSkipsDeviceResolution(t *testing.T) {
|
|
swapIOSResolveSeams(t)
|
|
iosResolveTarget = func(context.Context, string) (string, bool, error) {
|
|
return "sim-udid", true, nil
|
|
}
|
|
ensured := false
|
|
iosEnsureSimulator = func(context.Context, string, io.Writer) error { ensured = true; return nil }
|
|
iosResolveDevice = func(context.Context, string) (ios.Device, error) {
|
|
t.Fatal("simulator path must not resolve a physical device")
|
|
return ios.Device{}, nil
|
|
}
|
|
|
|
options := Options{Platform: "ios", IosDevice: "iPhone 17 Pro"}
|
|
resolved, err := resolveIOSTarget(context.Background(), options, io.Discard)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !ensured {
|
|
t.Fatal("simulator path must ensure the simulator is booted")
|
|
}
|
|
if !resolved.iosIsSimulator || resolved.iosUDID != "sim-udid" {
|
|
t.Fatalf("resolved = %+v, want the booted simulator", resolved)
|
|
}
|
|
}
|
|
|
|
func TestResolveIOSTargetDeviceResolutionErrorSurfaces(t *testing.T) {
|
|
swapIOSResolveSeams(t)
|
|
iosResolveTarget = func(_ context.Context, query string) (string, bool, error) { return query, false, nil }
|
|
iosResolveDevice = func(context.Context, string) (ios.Device, error) {
|
|
return ios.Device{}, errors.New("no device connected")
|
|
}
|
|
options := Options{Platform: "ios", IosDevice: "iPhone"}
|
|
if _, err := resolveIOSTarget(context.Background(), options, io.Discard); err == nil {
|
|
t.Fatal("expected the device-resolution error to surface")
|
|
}
|
|
}
|
|
|
|
func fakeJava(t *testing.T, script string) {
|
|
t.Helper()
|
|
directory := t.TempDir()
|
|
path := filepath.Join(directory, "java")
|
|
if err := os.WriteFile(path, []byte("#!/bin/sh\n"+script+"\n"), 0o755); err != nil {
|
|
t.Fatalf("write fake java: %v", err)
|
|
}
|
|
t.Setenv("PATH", directory)
|
|
}
|
|
|
|
func TestSidecarArgumentsSilencesTheUnsafeWarningOnAJvmThatTakesTheFlag(t *testing.T) {
|
|
fakeJava(t, "exit 0")
|
|
|
|
args := sidecarArguments(context.Background(), "/tmp/sidecar.jar", 51129, "android", "663c91b1")
|
|
|
|
want := []string{
|
|
"--sun-misc-unsafe-memory-access=allow",
|
|
"-jar", "/tmp/sidecar.jar",
|
|
"--port", "51129",
|
|
"--platform", "android",
|
|
"--serial", "663c91b1",
|
|
}
|
|
if !slices.Equal(args, want) {
|
|
t.Fatalf("sidecar argv = %q, want %q", args, want)
|
|
}
|
|
}
|
|
|
|
func TestSidecarArgumentsOmitsTheFlagAJvmBefore23WouldRefuseToStartWith(t *testing.T) {
|
|
fakeJava(t, `case "$1" in --sun-misc-unsafe-memory-access=*) echo "Unrecognized option: $1" >&2; exit 1;; esac; exit 0`)
|
|
|
|
args := sidecarArguments(context.Background(), "/tmp/sidecar.jar", 51129, "android", "")
|
|
|
|
want := []string{"-jar", "/tmp/sidecar.jar", "--port", "51129", "--platform", "android"}
|
|
if !slices.Equal(args, want) {
|
|
t.Fatalf("sidecar argv = %q, want %q", args, want)
|
|
}
|
|
}
|