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.
This commit is contained in:
pj authored and GitHub committed 2026-09-05 23:54:12 +05:30
1 parent 9b4ff5f247
commit 9ab59365b2
25 files changed
+1287 -98

No files matched your search

+228
View File
@@ -0,0 +1,228 @@
package android
import (
"archive/zip"
"encoding/binary"
"errors"
"fmt"
"io"
"unicode/utf16"
)
const (
manifestEntry = "AndroidManifest.xml"
chunkStringPool = 0x0001
chunkStartElement = 0x0102
stringPoolUTF8 = 1 << 8
)
var errStringPoolRange = errors.New("string pool entry runs past the chunk")
// PackageName reads an APK's application id out of its compiled manifest,
// which carries the id the package manager will know the app by, suffixes and
// all. Parsing it here rather than shelling out to `aapt2 dump packagename`
// keeps the read working on the many hosts that install platform-tools for adb
// and never install the versioned build-tools directory aapt2 lives in.
func PackageName(apkPath string) (string, error) {
manifest, err := manifestFromAPK(apkPath)
if err != nil {
return "", err
}
name, err := manifestPackage(manifest)
if err != nil {
return "", fmt.Errorf("%s: %w", apkPath, err)
}
return name, nil
}
func manifestFromAPK(apkPath string) ([]byte, error) {
archive, err := zip.OpenReader(apkPath)
if err != nil {
return nil, fmt.Errorf("open %s: %w", apkPath, err)
}
defer archive.Close()
for _, file := range archive.File {
if file.Name != manifestEntry {
continue
}
entry, err := file.Open()
if err != nil {
return nil, fmt.Errorf("open %s in %s: %w", manifestEntry, apkPath, err)
}
defer entry.Close()
manifest, err := io.ReadAll(entry)
if err != nil {
return nil, fmt.Errorf("read %s in %s: %w", manifestEntry, apkPath, err)
}
return manifest, nil
}
return nil, fmt.Errorf("%s holds no %s", apkPath, manifestEntry)
}
// manifestPackage walks the compiled manifest's chunks for the <manifest>
// element and reports its package attribute. The string pool always precedes
// the elements that index into it.
func manifestPackage(manifest []byte) (string, error) {
if len(manifest) < 8 {
return "", errors.New("truncated binary xml")
}
var pool []string
for offset := uint32(8); offset+8 <= uint32(len(manifest)); {
size := binary.LittleEndian.Uint32(manifest[offset+4:])
if size < 8 || uint64(offset)+uint64(size) > uint64(len(manifest)) {
return "", errors.New("truncated chunk in binary xml")
}
chunk := manifest[offset : offset+size]
switch binary.LittleEndian.Uint16(chunk) {
case chunkStringPool:
var err error
if pool, err = poolStrings(chunk); err != nil {
return "", err
}
case chunkStartElement:
name, found, err := packageAttribute(chunk, pool)
if err != nil || found {
return name, err
}
}
offset += size
}
return "", errors.New("no <manifest> element in binary xml")
}
// packageAttribute reports the package attribute of a start-element chunk, and
// whether that chunk was the <manifest> element at all.
func packageAttribute(chunk []byte, pool []string) (string, bool, error) {
// The element's own fields start after the chunk header, line number and
// comment index, and the attribute offsets are relative to there.
const element = 16
if len(chunk) < element+20 {
return "", false, errors.New("truncated start-element chunk")
}
if poolString(pool, binary.LittleEndian.Uint32(chunk[element+4:])) != "manifest" {
return "", false, nil
}
start := uint64(binary.LittleEndian.Uint16(chunk[element+8:]))
stride := uint64(binary.LittleEndian.Uint16(chunk[element+10:]))
count := uint64(binary.LittleEndian.Uint16(chunk[element+12:]))
if stride < 20 {
return "", false, fmt.Errorf("<manifest> attribute stride is %d bytes, want at least 20", stride)
}
for index := uint64(0); index < count; index++ {
at := element + start + index*stride
if at+20 > uint64(len(chunk)) {
return "", false, errors.New("<manifest> attribute runs past the chunk")
}
if poolString(pool, binary.LittleEndian.Uint32(chunk[at+4:])) != "package" {
continue
}
name := poolString(pool, binary.LittleEndian.Uint32(chunk[at+8:]))
if name == "" {
// aapt2 leaves the raw value unset and keeps the string in the
// typed value's data word.
name = poolString(pool, binary.LittleEndian.Uint32(chunk[at+16:]))
}
if name == "" {
return "", false, errors.New("<manifest> package attribute is empty")
}
return name, true, nil
}
return "", false, errors.New("<manifest> has no package attribute")
}
func poolString(pool []string, index uint32) string {
if uint64(index) >= uint64(len(pool)) {
return ""
}
return pool[index]
}
func poolStrings(chunk []byte) ([]string, error) {
const header = 28
if len(chunk) < header {
return nil, errors.New("truncated string pool")
}
count := uint64(binary.LittleEndian.Uint32(chunk[8:]))
flags := binary.LittleEndian.Uint32(chunk[16:])
start := uint64(binary.LittleEndian.Uint32(chunk[20:]))
if header+4*count > uint64(len(chunk)) {
return nil, errors.New("string pool offsets run past the chunk")
}
pool := make([]string, count)
for index := uint64(0); index < count; index++ {
at := start + uint64(binary.LittleEndian.Uint32(chunk[header+4*index:]))
value, err := poolString8Or16(chunk, at, flags&stringPoolUTF8 != 0)
if err != nil {
return nil, err
}
pool[index] = value
}
return pool, nil
}
func poolString8Or16(chunk []byte, at uint64, utf8 bool) (string, error) {
if utf8 {
// The character count precedes the byte count; only the second governs
// how far the string reaches.
at, _, err := prefixLength8(chunk, at)
if err != nil {
return "", err
}
at, length, err := prefixLength8(chunk, at)
if err != nil {
return "", err
}
if at+length > uint64(len(chunk)) {
return "", errStringPoolRange
}
return string(chunk[at : at+length]), nil
}
at, length, err := prefixLength16(chunk, at)
if err != nil {
return "", err
}
if at+2*length > uint64(len(chunk)) {
return "", errStringPoolRange
}
units := make([]uint16, length)
for index := range units {
units[index] = binary.LittleEndian.Uint16(chunk[at+2*uint64(index):])
}
return string(utf16.Decode(units)), nil
}
// prefixLength8 reads a UTF-8 pool string's length prefix: one byte, or two
// when the high bit marks the value as wider. It returns the offset past the
// prefix.
func prefixLength8(chunk []byte, at uint64) (uint64, uint64, error) {
if at >= uint64(len(chunk)) {
return 0, 0, errStringPoolRange
}
length := uint64(chunk[at])
if length&0x80 == 0 {
return at + 1, length, nil
}
if at+1 >= uint64(len(chunk)) {
return 0, 0, errStringPoolRange
}
return at + 2, (length&0x7f)<<8 | uint64(chunk[at+1]), nil
}
// prefixLength16 reads a UTF-16 pool string's length prefix, in units rather
// than bytes: one 16-bit word, or two when the high bit marks the value as
// wider.
func prefixLength16(chunk []byte, at uint64) (uint64, uint64, error) {
if at+2 > uint64(len(chunk)) {
return 0, 0, errStringPoolRange
}
length := uint64(binary.LittleEndian.Uint16(chunk[at:]))
if length&0x8000 == 0 {
return at + 2, length, nil
}
if at+4 > uint64(len(chunk)) {
return 0, 0, errStringPoolRange
}
return at + 4, (length&0x7fff)<<16 | uint64(binary.LittleEndian.Uint16(chunk[at+2:])), nil
}
+173
View File
@@ -0,0 +1,173 @@
package android
import (
"archive/zip"
"bytes"
"encoding/binary"
"os"
"path/filepath"
"strings"
"testing"
)
// folioAPK is the folio debug APK stripped to the one entry the reader looks
// at, so the parse runs against a manifest aapt2 really produced.
const folioAPK = "testdata/folio.apk"
func TestPackageName_ReadsCompiledManifest(t *testing.T) {
name, err := PackageName(folioAPK)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if name != "app.folio" {
t.Fatalf("package name: got %q, want app.folio", name)
}
}
func TestPackageName_UTF8StringPool(t *testing.T) {
name, err := PackageName(apkWithManifest(t, utf8PooledManifest("com.example.utf8")))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if name != "com.example.utf8" {
t.Fatalf("package name: got %q, want com.example.utf8", name)
}
}
func TestPackageName_NotAnAPK(t *testing.T) {
path := filepath.Join(t.TempDir(), "app.apk")
if err := os.WriteFile(path, []byte("this is not a zip"), 0o600); err != nil {
t.Fatalf("write file: %v", err)
}
_, err := PackageName(path)
if err == nil || !strings.Contains(err.Error(), path) {
t.Fatalf("expected an error naming %s, got %v", path, err)
}
}
func TestPackageName_NoManifestEntry(t *testing.T) {
path := filepath.Join(t.TempDir(), "app.apk")
file, err := os.Create(path)
if err != nil {
t.Fatalf("create apk: %v", err)
}
archive := zip.NewWriter(file)
if _, err := archive.Create("classes.dex"); err != nil {
t.Fatalf("write entry: %v", err)
}
if err := archive.Close(); err != nil {
t.Fatalf("close archive: %v", err)
}
if err := file.Close(); err != nil {
t.Fatalf("close apk: %v", err)
}
_, err = PackageName(path)
if err == nil || !strings.Contains(err.Error(), "AndroidManifest.xml") {
t.Fatalf("expected a missing-manifest error, got %v", err)
}
}
func TestPackageName_TruncatedManifest(t *testing.T) {
manifest, err := manifestFromAPK(folioAPK)
if err != nil {
t.Fatalf("read fixture manifest: %v", err)
}
for _, keep := range []int{4, 40, 400} {
if _, err := PackageName(apkWithManifest(t, manifest[:keep])); err == nil {
t.Fatalf("expected an error from a manifest cut to %d bytes", keep)
}
}
}
func apkWithManifest(t *testing.T, manifest []byte) string {
t.Helper()
path := filepath.Join(t.TempDir(), "app.apk")
file, err := os.Create(path)
if err != nil {
t.Fatalf("create apk: %v", err)
}
archive := zip.NewWriter(file)
entry, err := archive.Create(manifestEntry)
if err != nil {
t.Fatalf("create manifest entry: %v", err)
}
if _, err := entry.Write(manifest); err != nil {
t.Fatalf("write manifest entry: %v", err)
}
if err := archive.Close(); err != nil {
t.Fatalf("close archive: %v", err)
}
if err := file.Close(); err != nil {
t.Fatalf("close apk: %v", err)
}
return path
}
// utf8PooledManifest encodes the smallest manifest that carries a package
// name, with the UTF-8 string pool that aapt2 does not emit and older builders
// do. Without it the UTF-8 decoding path is never exercised: the compiled
// manifests aapt2 writes all pool their strings as UTF-16.
func utf8PooledManifest(packageName string) []byte {
pool := []string{"manifest", "package", packageName}
var pooled bytes.Buffer
offsets := make([]uint32, len(pool))
for index, value := range pool {
offsets[index] = uint32(pooled.Len())
pooled.WriteByte(byte(len(value)))
pooled.WriteByte(byte(len(value)))
pooled.WriteString(value)
pooled.WriteByte(0)
}
stringsStart := uint32(28 + 4*len(pool))
var poolChunk bytes.Buffer
write16(&poolChunk, chunkStringPool)
write16(&poolChunk, 28)
write32(&poolChunk, stringsStart+uint32(pooled.Len()))
write32(&poolChunk, uint32(len(pool)))
write32(&poolChunk, 0)
write32(&poolChunk, stringPoolUTF8)
write32(&poolChunk, stringsStart)
write32(&poolChunk, 0)
for _, offset := range offsets {
write32(&poolChunk, offset)
}
poolChunk.Write(pooled.Bytes())
var element bytes.Buffer
write16(&element, chunkStartElement)
write16(&element, 16)
write32(&element, 36+20)
write32(&element, 1) // line number
write32(&element, 0xFFFFFFFF) // comment
write32(&element, 0xFFFFFFFF) // namespace
write32(&element, 0) // name: "manifest"
write16(&element, 20) // attributes start, past this header
write16(&element, 20) // attribute stride
write16(&element, 1) // attribute count
write16(&element, 0) // id index
write16(&element, 0) // class index
write16(&element, 0) // style index
write32(&element, 0xFFFFFFFF) // attribute namespace
write32(&element, 1) // attribute name: "package"
write32(&element, 2) // attribute raw value: the package name
write16(&element, 8) // typed value size
write16(&element, 3<<8) // res0, and TYPE_STRING
write32(&element, 2)
var manifest bytes.Buffer
write16(&manifest, 3)
write16(&manifest, 8)
write32(&manifest, uint32(8+poolChunk.Len()+element.Len()))
manifest.Write(poolChunk.Bytes())
manifest.Write(element.Bytes())
return manifest.Bytes()
}
func write16(buffer *bytes.Buffer, value uint16) {
_ = binary.Write(buffer, binary.LittleEndian, value)
}
func write32(buffer *bytes.Buffer, value uint32) {
_ = binary.Write(buffer, binary.LittleEndian, value)
}
Binary file not shown.
+21
View File
@@ -667,6 +667,27 @@ func (t *Tree) Transitional() bool {
return false
}
// ScreenName names the route this tree shows: the driver-set screen when the
// platform reports one (web), otherwise the resource id ending in "Screen" that
// marks the route composable. A transitional tree names no screen.
func (t *Tree) ScreenName() string {
if t == nil || len(t.Elements) == 0 {
return ""
}
if screen := t.Elements[0].Screen; screen != "" {
return screen
}
if t.Transitional() {
return ""
}
for _, element := range t.Elements {
if strings.HasSuffix(element.ResourceID, "Screen") {
return element.ResourceID
}
}
return ""
}
// Find returns the first element matching the selector, or nil.
func (t *Tree) Find(selector string) *Element {
node := t.FindNode(selector)
+45
View File
@@ -1821,3 +1821,48 @@ func TestTagMatchesTheElementItNames(t *testing.T) {
})
}
}
func TestScreenNameNamesTheRouteTheTreeShows(t *testing.T) {
cases := []struct {
name string
tree string
want string
}{
{
"driver-set screen",
`{"attributes": {"bounds": "[0,0,10,10]", "sanderling-screen": "/ledger"}, "children": []}`,
"/ledger",
},
{
"route marker",
`{"attributes": {"bounds": "[0,0,10,10]"}, "children": [
{"attributes": {"resource-id": "HomeScreen", "bounds": "[0,0,10,10]"}, "children": []}
]}`,
"HomeScreen",
},
{
"cross-fade names no screen",
`{"attributes": {"bounds": "[0,0,10,10]"}, "children": [
{"attributes": {"resource-id": "HomeScreen", "bounds": "[0,0,10,10]"}, "children": []},
{"attributes": {"resource-id": "LedgerScreen", "bounds": "[0,0,10,10]"}, "children": []}
]}`,
"",
},
{
"no marker at all",
`{"attributes": {"bounds": "[0,0,10,10]"}, "children": []}`,
"",
},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
tree, err := Parse(testCase.tree)
if err != nil {
t.Fatalf("Parse: %v", err)
}
if got := tree.ScreenName(); got != testCase.want {
t.Errorf("ScreenName = %q, want %q", got, testCase.want)
}
})
}
}
+59
View File
@@ -0,0 +1,59 @@
package runner
import (
"bytes"
"log/slog"
"strings"
"testing"
"github.com/priyanshujain/sanderling/internal/verifier"
)
func logStepLine(t *testing.T, action verifier.Action, treeJSON string) string {
t.Helper()
var buffer bytes.Buffer
logger := slog.New(slog.NewTextHandler(&buffer, nil))
logStep(logger, 7, "LoginScreen", 42, action, nil, "", mustParseTree(t, treeJSON))
return buffer.String()
}
func TestLogStepNamesTheActionItsTargetAndTheTypedValue(t *testing.T) {
line := logStepLine(t, typeInto("id:LoginEmail"), iosLoginTreeJSON)
for _, want := range []string{
"index=7", "screen=LoginScreen", "nodes=42",
"action=InputText", "target=id:LoginEmail", typedCredential,
} {
if !strings.Contains(line, want) {
t.Errorf("step log = %q, want it to carry %q", line, want)
}
}
}
func TestLogStepRedactsTheTypedValueOfASecureField(t *testing.T) {
line := logStepLine(t, typeInto("id:LoginPassword"), iosLoginTreeJSON)
if strings.Contains(line, typedCredential) {
t.Errorf("step log = %q, want the typed credential withheld", line)
}
if !strings.Contains(line, verifier.RedactedInputText) {
t.Errorf("step log = %q, want %q", line, verifier.RedactedInputText)
}
if !strings.Contains(line, "target=id:LoginPassword") {
t.Errorf("step log = %q, want it to still name the field typed into", line)
}
}
func TestLogStepReportsAStepThatActedOnNothing(t *testing.T) {
var buffer bytes.Buffer
logger := slog.New(slog.NewTextHandler(&buffer, nil))
logStep(logger, 3, "", 0, verifier.Action{}, verifier.ErrNoAction, actionSkippedNoActionProduced, nil)
line := buffer.String()
if !strings.Contains(line, "action=none") {
t.Errorf("step log = %q, want action=none", line)
}
if !strings.Contains(line, "skipped="+string(actionSkippedNoActionProduced)) {
t.Errorf("step log = %q, want the skip reason", line)
}
}
+5 -5
View File
@@ -40,9 +40,9 @@ const webLoginTreeJSON = `{
]
}`
// androidLoginTreeJSON is the same form on Android, where the native tree
// mapper drops uiautomator's password attribute and neither field carries the
// fact.
// androidLoginTreeJSON is the same form with the fact missing from both fields,
// which is what an Android tree looks like when the sidecar could not match its
// text fields against the device's own view hierarchy.
const androidLoginTreeJSON = `{
"attributes": {"bounds": "[0,0,1080,2340]"},
"children": [
@@ -65,8 +65,8 @@ func TestTraceActionForRedactsTypedValuesTheTargetCannotClear(t *testing.T) {
}{
{"ios secure field", iosLoginTreeJSON, "id:LoginPassword"},
{"web secure field", webLoginTreeJSON, "id:login-password"},
{"android field reported as neither", androidLoginTreeJSON, "id:login_email"},
{"android password field", androidLoginTreeJSON, "id:login_password"},
{"field reported as neither", androidLoginTreeJSON, "id:login_email"},
{"password field reported as neither", androidLoginTreeJSON, "id:login_password"},
} {
t.Run(testCase.name, func(t *testing.T) {
traceAction := traceActionFor(typeInto(testCase.selector), mustParseTree(t, testCase.treeJSON))
+32 -6
View File
@@ -236,10 +236,7 @@ func Run(ctx context.Context, options Options) (Summary, error) {
}
lastLogTime = stepStart
screen := ""
if tree != nil && len(tree.Elements) > 0 {
screen = tree.Elements[0].Screen
}
screen := tree.ScreenName()
// A transitional tree is one nothing can vouch for: a NavHost mid
// cross-fade, a screen that changed shape between two reads, or a
@@ -332,8 +329,6 @@ func Run(ctx context.Context, options Options) (Summary, error) {
logger.Warn("unsettled tree; skipping verifier",
"step", stepIndex, "screen", screen, "nodes", treeSize)
}
logger.Info("step", "index", stepIndex, "screen", screen, "nodes", treeSize)
// A frame the verifier would not look at is not one to act on either.
// #75 is the fuzzer tapping into a screen that is still filling in, and
// holding the action back is also what keeps the spec's view of the run
@@ -474,6 +469,8 @@ func Run(ctx context.Context, options Options) (Summary, error) {
// the action it points at is still the one the next verified step has to
// be told about.
logStep(logger, stepIndex, screen, treeSize, nextAction, nextErr, actionSkipped, tree)
step := trace.Step{
Index: stepIndex,
Timestamp: stepStart,
@@ -1578,6 +1575,35 @@ func driverIsAndroid(ctx context.Context, options Options, logger *slog.Logger)
return health.Platform == "android"
}
// logStep prints the one line a run emits per step: what screen it saw and what
// it did there. The typed value goes through the same redaction the trace and
// the prompt use, so the console cannot publish a credential the records
// withhold.
func logStep(
logger *slog.Logger,
stepIndex int,
screen string,
treeSize int,
action verifier.Action,
actionErr error,
skipped actionSkipReason,
tree *hierarchy.Tree,
) {
attrs := []any{"index", stepIndex, "screen", screen, "nodes", treeSize}
if actionErr != nil {
attrs = append(attrs, "action", "none")
} else {
attrs = append(attrs, "action", string(action.Kind), "target", actionTarget(action))
if action.Kind == verifier.ActionKindInputText {
attrs = append(attrs, "text", verifier.RecordedActionText(action, tree))
}
}
if skipped != "" {
attrs = append(attrs, "skipped", string(skipped))
}
logger.Info("step", attrs...)
}
func traceActionFor(action verifier.Action, tree *hierarchy.Tree) *trace.Action {
traceAction := &trace.Action{
Kind: string(action.Kind),
+4 -4
View File
@@ -1,4 +1,4 @@
{"extractor_changes":{"extractor_0":{"prev":null,"curr":0}},"hierarchy":{"elements":[{"resourceId":"HomeScreen","bounds":{"left":0,"top":0,"right":0,"bottom":0},"attrs":{"editable":"false","resource-id":"HomeScreen"}},{"resourceId":"next","clickable":true,"enabled":true,"bounds":{"left":40,"top":80,"right":240,"bottom":160},"attrs":{"bounds":"[40,80,240,160]","clickable":"true","editable":"false","enabled":"true","resource-id":"next"}}],"depths":[0,1]},"next_action":{"kind":"Tap","selector":"id:next","resolved_bounds":{"x":40,"y":80,"width":200,"height":80},"tap_point":{"x":140,"y":120},"source":"seeded"},"residuals":{"balanceNonNegative":{"op":"true"}},"step":1,"timestamp":"0001-01-01T00:00:00Z","trace_version":1}
{"hierarchy":{"elements":[{"resourceId":"HomeScreen","bounds":{"left":0,"top":0,"right":0,"bottom":0},"attrs":{"editable":"false","resource-id":"HomeScreen"}},{"resourceId":"next","clickable":true,"enabled":true,"bounds":{"left":40,"top":80,"right":240,"bottom":160},"attrs":{"bounds":"[40,80,240,160]","clickable":"true","editable":"false","enabled":"true","resource-id":"next"}}],"depths":[0,1]},"next_action":{"kind":"Tap","selector":"id:next","resolved_bounds":{"x":40,"y":80,"width":200,"height":80},"tap_point":{"x":140,"y":120},"source":"seeded"},"residuals":{"balanceNonNegative":{"op":"true"}},"step":2,"timestamp":"0001-01-01T00:00:00Z","trace_version":1}
{"hierarchy":{"elements":[{"resourceId":"HomeScreen","bounds":{"left":0,"top":0,"right":0,"bottom":0},"attrs":{"editable":"false","resource-id":"HomeScreen"}},{"resourceId":"next","clickable":true,"enabled":true,"bounds":{"left":40,"top":80,"right":240,"bottom":160},"attrs":{"bounds":"[40,80,240,160]","clickable":"true","editable":"false","enabled":"true","resource-id":"next"}}],"depths":[0,1]},"next_action":{"kind":"Tap","selector":"id:next","resolved_bounds":{"x":40,"y":80,"width":200,"height":80},"tap_point":{"x":140,"y":120},"source":"seeded"},"residuals":{"balanceNonNegative":{"op":"true"}},"step":3,"timestamp":"0001-01-01T00:00:00Z","trace_version":1}
{"hierarchy":{"elements":[{"resourceId":"HomeScreen","bounds":{"left":0,"top":0,"right":0,"bottom":0},"attrs":{"editable":"false","resource-id":"HomeScreen"}},{"resourceId":"next","clickable":true,"enabled":true,"bounds":{"left":40,"top":80,"right":240,"bottom":160},"attrs":{"bounds":"[40,80,240,160]","clickable":"true","editable":"false","enabled":"true","resource-id":"next"}}],"depths":[0,1]},"next_action":{"kind":"Tap","selector":"id:next","resolved_bounds":{"x":40,"y":80,"width":200,"height":80},"tap_point":{"x":140,"y":120},"source":"seeded"},"residuals":{"balanceNonNegative":{"op":"true"}},"step":4,"timestamp":"0001-01-01T00:00:00Z","trace_version":1}
{"extractor_changes":{"extractor_0":{"prev":null,"curr":0}},"hierarchy":{"elements":[{"resourceId":"HomeScreen","bounds":{"left":0,"top":0,"right":0,"bottom":0},"attrs":{"editable":"false","resource-id":"HomeScreen"}},{"resourceId":"next","clickable":true,"enabled":true,"bounds":{"left":40,"top":80,"right":240,"bottom":160},"attrs":{"bounds":"[40,80,240,160]","clickable":"true","editable":"false","enabled":"true","resource-id":"next"}}],"depths":[0,1]},"next_action":{"kind":"Tap","selector":"id:next","resolved_bounds":{"x":40,"y":80,"width":200,"height":80},"tap_point":{"x":140,"y":120},"source":"seeded"},"residuals":{"balanceNonNegative":{"op":"true"}},"screen":"HomeScreen","step":1,"timestamp":"0001-01-01T00:00:00Z","trace_version":1}
{"hierarchy":{"elements":[{"resourceId":"HomeScreen","bounds":{"left":0,"top":0,"right":0,"bottom":0},"attrs":{"editable":"false","resource-id":"HomeScreen"}},{"resourceId":"next","clickable":true,"enabled":true,"bounds":{"left":40,"top":80,"right":240,"bottom":160},"attrs":{"bounds":"[40,80,240,160]","clickable":"true","editable":"false","enabled":"true","resource-id":"next"}}],"depths":[0,1]},"next_action":{"kind":"Tap","selector":"id:next","resolved_bounds":{"x":40,"y":80,"width":200,"height":80},"tap_point":{"x":140,"y":120},"source":"seeded"},"residuals":{"balanceNonNegative":{"op":"true"}},"screen":"HomeScreen","step":2,"timestamp":"0001-01-01T00:00:00Z","trace_version":1}
{"hierarchy":{"elements":[{"resourceId":"HomeScreen","bounds":{"left":0,"top":0,"right":0,"bottom":0},"attrs":{"editable":"false","resource-id":"HomeScreen"}},{"resourceId":"next","clickable":true,"enabled":true,"bounds":{"left":40,"top":80,"right":240,"bottom":160},"attrs":{"bounds":"[40,80,240,160]","clickable":"true","editable":"false","enabled":"true","resource-id":"next"}}],"depths":[0,1]},"next_action":{"kind":"Tap","selector":"id:next","resolved_bounds":{"x":40,"y":80,"width":200,"height":80},"tap_point":{"x":140,"y":120},"source":"seeded"},"residuals":{"balanceNonNegative":{"op":"true"}},"screen":"HomeScreen","step":3,"timestamp":"0001-01-01T00:00:00Z","trace_version":1}
{"hierarchy":{"elements":[{"resourceId":"HomeScreen","bounds":{"left":0,"top":0,"right":0,"bottom":0},"attrs":{"editable":"false","resource-id":"HomeScreen"}},{"resourceId":"next","clickable":true,"enabled":true,"bounds":{"left":40,"top":80,"right":240,"bottom":160},"attrs":{"bounds":"[40,80,240,160]","clickable":"true","editable":"false","enabled":"true","resource-id":"next"}}],"depths":[0,1]},"next_action":{"kind":"Tap","selector":"id:next","resolved_bounds":{"x":40,"y":80,"width":200,"height":80},"tap_point":{"x":140,"y":120},"source":"seeded"},"residuals":{"balanceNonNegative":{"op":"true"}},"screen":"HomeScreen","step":4,"timestamp":"0001-01-01T00:00:00Z","trace_version":1}
+20 -7
View File
@@ -154,13 +154,7 @@ func buildDriver(ctx context.Context, options Options, stdout io.Writer) (driver
if err != nil {
return nil, nil, err
}
sidecarArgs := []string{"-jar", jarPath,
"--port", strconv.Itoa(sidecarPort),
"--platform", options.Platform,
}
if options.Device != "" {
sidecarArgs = append(sidecarArgs, "--serial", options.Device)
}
sidecarArgs := sidecarArguments(ctx, jarPath, sidecarPort, options.Platform, options.Device)
adbPath, err := android.AdbBinary()
if err != nil {
return nil, nil, preflightFailure("android", err)
@@ -212,6 +206,25 @@ func buildDriver(ctx context.Context, options Options, stdout io.Writer) (driver
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.
+41
View File
@@ -4,7 +4,10 @@ import (
"context"
"errors"
"io"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"testing"
"time"
@@ -305,3 +308,41 @@ func TestResolveIOSTargetDeviceResolutionErrorSurfaces(t *testing.T) {
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)
}
}
+5 -5
View File
@@ -76,11 +76,11 @@ func RecordedAction(action Action, tree *hierarchy.Tree) Action {
// have produced.
//
// A target the platform reports as a secure entry is redacted, and so is a
// target carrying no report at all. iOS and web state the fact on every
// editable element, so a missing one means Android, whose native tree mapper
// drops uiautomator's password attribute before the sidecar sees it. There a
// password field cannot be told from a search box, and the target that cannot
// be told apart is treated as the credential.
// target carrying no report at all. All three platforms state the fact on their
// editable elements, so a missing one is a field none of them could speak for:
// an action that named no target, or an Android text field the sidecar could
// not match against the device's own view hierarchy. The target that cannot be
// told apart is treated as the credential.
func recordedInputText(text string, target secureFact) string {
if target.reported && !target.secure {
return text