From b1e95739ad3bedca0b0be1cf37af929905f3f19e Mon Sep 17 00:00:00 2001 From: PJ Date: Tue, 18 Aug 2026 17:16:55 +0530 Subject: [PATCH] feat(hierarchy): an element reports whether it masks what is typed into it ios reads it off SecureTextField, which the companion already sent and nothing read; web reads input[type=password]. Android cannot: the native tree mapper drops the password attribute before the sidecar sees it, so the fact is three-valued and null there rather than a false that would read as "not secure". --- internal/driver/chrome/driver.go | 4 ++ internal/driver/chrome/element_state_test.go | 37 +++++++++++++++ .../driver/chrome/testdata/element-state.html | 1 + internal/driver/ioscompanion/hierarchymap.go | 9 +++- .../driver/ioscompanion/hierarchymap_test.go | 38 +++++++++++++++ internal/hierarchy/hierarchy.go | 20 +++++++- internal/hierarchy/hierarchy_test.go | 47 +++++++++++++++++++ pkg/spec/src/types.ts | 3 ++ pkg/spec/src/web-runtime.ts | 11 ++++- pkg/spec/test/fixtures/selector-keys.json | 3 +- pkg/spec/test/web-runtime.test.ts | 18 ++++++- 11 files changed, 186 insertions(+), 5 deletions(-) diff --git a/internal/driver/chrome/driver.go b/internal/driver/chrome/driver.go index cd5cb0a..2e05888 100644 --- a/internal/driver/chrome/driver.go +++ b/internal/driver/chrome/driver.go @@ -746,6 +746,10 @@ func (d *Driver) Hierarchy(ctx context.Context) (string, error) { // emitted is the flag the field declares and not the property's value. checked: el.checked === true || null, selected: el.selected === true || null, + // Emitted as a plain boolean, never null, on every editable field: a + // consumer deciding what a typed value may be recorded as has to tell + // "not a secure entry" apart from "nobody said", and android says nothing. + secure: isEditable ? el.type === 'password' : null, // Emitted as a plain boolean, never null: internal/hierarchy falls back to // the native heuristic when the field is absent, which reads any class // name containing "EditText" as an Android text widget. On web that is a diff --git a/internal/driver/chrome/element_state_test.go b/internal/driver/chrome/element_state_test.go index ed62e5e..bd6a8dc 100644 --- a/internal/driver/chrome/element_state_test.go +++ b/internal/driver/chrome/element_state_test.go @@ -191,6 +191,43 @@ func TestElementState_ReportsTheOtherDocumentedBooleans(t *testing.T) { ) } +// Every editable field states `secure`, false included. internal/verifier +// redacts a typed value whenever the target does not positively report "not a +// secure entry", so a dump that omitted the key on an ordinary text field would +// redact the whole recent-action memory on web. +func TestElementState_SecureStatesEveryEditableField(t *testing.T) { + server := httptest.NewServer(http.FileServer(http.Dir("testdata"))) + defer server.Close() + + d := New() + defer d.Terminate(context.Background()) + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + if err := d.Launch(ctx, server.URL+"/element-state.html", false, nil); err != nil { + t.Fatalf("Launch: %v", err) + } + + for _, testCase := range []struct { + selector string + want bool + }{ + {"id:secret", true}, + {"id:editing", false}, + } { + element := elementInHierarchyDump(ctx, t, d, testCase.selector) + if !element.SecureReported() { + t.Errorf("the dump states no secure fact for %s", testCase.selector) + } + if element.Secure != testCase.want { + t.Errorf("%s secure = %v, want %v", testCase.selector, element.Secure, testCase.want) + } + } + + if button := elementInHierarchyDump(ctx, t, d, "id:save"); button.SecureReported() { + t.Error("a button is not a text entry and states nothing") + } +} + // Focus belongs to the node the user is typing into, not to the element the // shadow tree is mounted on. // diff --git a/internal/driver/chrome/testdata/element-state.html b/internal/driver/chrome/testdata/element-state.html index 8516c87..d100771 100644 --- a/internal/driver/chrome/testdata/element-state.html +++ b/internal/driver/chrome/testdata/element-state.html @@ -11,6 +11,7 @@ +