Files
pj 9ab59365b2 redact passwords only, and say what each step did in the log (#93)
* 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.
2026-09-05 23:54:12 +05:30

318 lines
11 KiB
Go

package testrun
import (
"context"
"fmt"
"io"
"net"
"os"
"os/exec"
"strconv"
"syscall"
"time"
"github.com/priyanshujain/sanderling/internal/android"
"github.com/priyanshujain/sanderling/internal/driver"
"github.com/priyanshujain/sanderling/internal/driver/chrome"
"github.com/priyanshujain/sanderling/internal/driver/ioscompanion"
driverSidecar "github.com/priyanshujain/sanderling/internal/driver/sidecar"
"github.com/priyanshujain/sanderling/internal/ios"
"github.com/priyanshujain/sanderling/internal/sidecarassets"
)
// iOS resolution seams: package-level so routing tests substitute canned
// resolvers instead of shelling out to xcrun.
var (
iosResolveTarget = ios.ResolveTarget
iosResolveDevice = ios.ResolveDevice
iosEnsureSimulator = ios.EnsureSimulator
)
// resolveIOSTarget decides whether a run drives a simulator or a physical
// device and fills the iOS fields on Options. A simulator target is booted if
// needed; a physical-device target is resolved to its hardware UDID and
// CoreDevice id via devicectl.
func resolveIOSTarget(ctx context.Context, options Options, stdout io.Writer) (Options, error) {
udid, isSimulator, err := iosResolveTarget(ctx, options.IosDevice)
if err != nil {
// No query and nothing booted: keep boot-first behavior, then resolve
// the simulator that EnsureSimulator just brought up.
if options.IosDevice == "" {
if bootErr := iosEnsureSimulator(ctx, "", stdout); bootErr != nil {
return options, bootErr
}
udid, isSimulator, err = iosResolveTarget(ctx, "")
}
if err != nil {
return options, err
}
} else if isSimulator {
if err := iosEnsureSimulator(ctx, options.IosDevice, stdout); err != nil {
return options, err
}
udid, isSimulator, err = iosResolveTarget(ctx, options.IosDevice)
if err != nil {
return options, err
}
}
options.iosUDID = udid
options.iosIsSimulator = isSimulator
if !isSimulator {
device, err := iosResolveDevice(ctx, options.IosDevice)
if err != nil {
return options, err
}
options.iosUDID = device.HardwareUDID
options.iosCoreDeviceID = device.CoreDeviceID
fmt.Fprintf(stdout, "using device: %s (udid %s, id %s)\n", device.Name, device.HardwareUDID, device.CoreDeviceID)
}
return options, nil
}
// preflight is a seam so routing tests exercise driver construction without the
// host-readiness checks (xcrun, java) that are absent on a Linux CI runner.
var preflight = Preflight
// newDeviceDriver constructs the physical-device iOS driver and its cleanup. A
// seam so routing tests assert the resolved identifiers reach DeviceOptions
// without building or spawning a real runner.
var newDeviceDriver = func(ctx context.Context, options ioscompanion.DeviceOptions) (driver.DeviceDriver, func(), error) {
d, err := ioscompanion.NewDevice(ctx, options)
if err != nil {
return nil, nil, err
}
return d, d.Close, nil
}
// newSimulatorDriver constructs the iOS simulator driver and its cleanup. A
// seam so routing tests assert the run's options reach ioscompanion.Options
// without spawning a companion.
var newSimulatorDriver = func(ctx context.Context, options ioscompanion.Options) (driver.DeviceDriver, func(), error) {
d, err := ioscompanion.New(ctx, options)
if err != nil {
return nil, nil, err
}
return d, d.Close, nil
}
// buildDriver creates the appropriate DeviceDriver for the platform and returns
// a cleanup function. For web, ChromeDriver is used directly. An iOS simulator
// is driven by the native simulator companion (no JVM). A physical iOS device
// is driven runner-only over a usbmux tunnel. Android uses the JVM sidecar,
// which is extracted, spawned, and dialed.
func buildDriver(ctx context.Context, options Options, stdout io.Writer) (driver.DeviceDriver, func(), error) {
if err := preflight(ctx, options.Platform); err != nil {
return nil, nil, err
}
if options.Platform == "web" {
d := chrome.New()
return d, func() { _ = d.Terminate(context.Background()) }, nil
}
if options.Platform == "ios" && options.iosIsSimulator {
d, cleanup, err := newSimulatorDriver(ctx, ioscompanion.Options{
UniqueDeviceIdentifier: options.iosUDID,
BundleID: options.BundleID,
AppPath: options.IosAppPath,
ClearState: options.ClearData,
Output: stdout,
})
if err != nil {
return nil, nil, fmt.Errorf("ios simulator driver: %w", err)
}
return d, cleanup, nil
}
if options.Platform == "ios" {
d, cleanup, err := newDeviceDriver(ctx, ioscompanion.DeviceOptions{
HardwareUDID: options.iosUDID,
CoreDeviceID: options.iosCoreDeviceID,
BundleID: options.BundleID,
AppPath: options.IosAppPath,
ClearState: options.ClearData,
Output: stdout,
})
if err != nil {
return nil, nil, fmt.Errorf("ios device driver: %w", err)
}
return d, cleanup, nil
}
// Android uses the JVM sidecar, which requires java.
if err := preflightDevice(options.Platform); err != nil {
return nil, nil, err
}
sidecarDirectory := os.TempDir() + "/sanderling-sidecar"
jarPath, err := sidecarassets.Extract(sidecarDirectory)
if err != nil {
return nil, nil, fmt.Errorf("extract sidecar: %w", err)
}
fmt.Fprintf(stdout, "sidecar JAR: %s (size=%d)\n", jarPath, sidecarassets.EmbeddedSize())
sidecarPort, err := pickFreePort()
if err != nil {
return nil, nil, err
}
sidecarArgs := sidecarArguments(ctx, jarPath, sidecarPort, options.Platform, options.Device)
adbPath, err := android.AdbBinary()
if err != nil {
return nil, nil, preflightFailure("android", err)
}
sidecarCommand := exec.CommandContext(ctx, "java", sidecarArgs...)
sidecarCommand.Stdout = stdout
sidecarCommand.Stderr = stdout
sidecarCommand.Env = android.EnvWithAndroidPlatformTools(os.Environ(), adbPath)
// SIGTERM lets the sidecar's shutdown hook stop the iOS XCTest runner.
// SIGKILL skips the hook and orphans an xcodebuild session that later
// restarts its runner and hijacks the simulator mid-run.
sidecarCommand.Cancel = func() error {
return sidecarCommand.Process.Signal(syscall.SIGTERM)
}
sidecarCommand.WaitDelay = sidecarShutdownGrace
if err := sidecarCommand.Start(); err != nil {
return nil, nil, fmt.Errorf("spawn sidecar: %w", err)
}
sidecarExited := watchSidecar(sidecarCommand)
address := fmt.Sprintf("127.0.0.1:%d", sidecarPort)
fmt.Fprintf(stdout, "sidecar pid=%d listening on %s (adb: %s)\n", sidecarCommand.Process.Pid, address, adbPath)
driverClient, err := driverSidecar.Dial(address)
if err != nil {
stopSidecar(sidecarCommand, sidecarExited)
return nil, nil, fmt.Errorf("dial sidecar: %w", err)
}
driverClient.SetPlatform(options.Platform)
driverClient.SetClearStateReinstall(options.Device, options.AndroidAppPath, stdout)
// WaitForHealth confirms the gRPC sidecar is up. For iOS, the WDA warmup
// (absorbing the XCUITest startup race) runs inside IosDriverBackend.init
// in the sidecar - no additional sleep needed here.
healthCtx, healthCancel := context.WithTimeout(ctx, sidecarStartupTimeout)
healthErr := awaitSidecar(healthCtx, address, sidecarStartupTimeout, func(pollCtx context.Context) error {
return driverClient.WaitForHealth(pollCtx, 250e6)
}, sidecarExited)
healthCancel()
if healthErr != nil {
stopSidecar(sidecarCommand, sidecarExited)
_ = driverClient.Close()
return nil, nil, healthErr
}
fmt.Fprintln(stdout, "sidecar is healthy")
cleanup := func() {
_ = driverClient.Close()
stopSidecar(sidecarCommand, sidecarExited)
}
return driverClient, cleanup, nil
}
// From JDK 24 the JVM prints a four-line sun.misc.Unsafe deprecation warning
// on every sidecar start, because the netty that grpc pulls in still reads
// field offsets through it. The flag that silences it did not exist before JDK
// 23 and an older JVM refuses to start when handed it, so ask this JVM whether
// it takes the flag rather than reading its version string.
const unsafeMemoryAccessAllow = "--sun-misc-unsafe-memory-access=allow"
func sidecarArguments(ctx context.Context, jarPath string, port int, platform, serial string) []string {
var args []string
if exec.CommandContext(ctx, "java", unsafeMemoryAccessAllow, "-version").Run() == nil {
args = append(args, unsafeMemoryAccessAllow)
}
args = append(args, "-jar", jarPath, "--port", strconv.Itoa(port), "--platform", platform)
if serial != "" {
args = append(args, "--serial", serial)
}
return args
}
// watchSidecar reaps the sidecar and publishes its exit status. The channel is
// closed after the send so the shutdown path can still receive once the startup
// path has taken the status.
func watchSidecar(sidecarCommand *exec.Cmd) <-chan error {
exited := make(chan error, 1)
go func() {
exited <- sidecarCommand.Wait()
close(exited)
}()
return exited
}
// awaitSidecar waits for the sidecar to answer a health check, racing that
// against the process exiting so a sidecar that dies during startup is reported
// as the exit it was rather than as a deadline half a minute later. Neither
// failure knows why the sidecar was unhappy, so both name what to look at
// instead of picking a cause.
func awaitSidecar(
ctx context.Context,
address string,
timeout time.Duration,
health func(context.Context) error,
exited <-chan error,
) error {
healthy := make(chan error, 1)
go func() { healthy <- health(ctx) }()
select {
case exitErr := <-exited:
return sidecarExitedError(address, exitErr)
case err := <-healthy:
if err == nil {
return nil
}
select {
case exitErr := <-exited:
return sidecarExitedError(address, exitErr)
default:
return fmt.Errorf(
"sidecar did not answer a health check on %s within %s and is still running\n%s",
address, timeout, sidecarWhatToCheck,
)
}
}
}
const sidecarWhatToCheck = "check the sidecar output above, then `sanderling doctor --platform=android` (java 17+, adb, Android SDK)"
func sidecarExitedError(address string, exitErr error) error {
status := "exit status 0"
if exitErr != nil {
status = exitErr.Error()
}
return fmt.Errorf(
"sidecar exited before it answered a health check on %s: %s\n%s",
address, status, sidecarWhatToCheck,
)
}
// sidecarShutdownGrace bounds how long the sidecar gets to run its shutdown
// hook (terminate the app, stop the XCTest runner) before being killed.
const sidecarShutdownGrace = 15 * time.Second
// stopSidecar terminates the sidecar gracefully so its shutdown hook can stop
// the device-side runner processes, escalating to SIGKILL when it does not
// exit within the grace window.
func stopSidecar(sidecarCommand *exec.Cmd, exited <-chan error) {
if sidecarCommand.Process == nil {
return
}
if err := sidecarCommand.Process.Signal(syscall.SIGTERM); err != nil {
_ = sidecarCommand.Process.Kill()
<-exited
return
}
select {
case <-exited:
case <-time.After(sidecarShutdownGrace):
_ = sidecarCommand.Process.Kill()
<-exited
}
}
func pickFreePort() (int, error) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return 0, err
}
defer listener.Close()
return listener.Addr().(*net.TCPAddr).Port, nil
}