test(chrome): compare the hint keys and placeholder across both matchers

The page gains four fields that differ one rung at a time: a bound
label, a placeholder, a placeholder an aria-label outranks, and the name
the form gives the field. Only the placeholder rung was reachable
before, so hintText and placeholderValue named a field on the goja host
and no element at all on web for the other three, and named the field
here and nothing there for the rung the ladder passed over.

placeholder was measured empty on both hosts because nothing on the page
carried the attribute, which said nothing about it. It now names the
field the markup wrote it on and not the field whose hint is its
aria-label.

The third resolver reads the same selectors: what TranslateStringSelector
builds for a hint key has to match nothing over CDP rather than the field
carrying the value as a placeholder.
This commit is contained in:
pj committed 2026-08-19 10:37:10 +05:30
1 parent f02a65772b
commit dbfbc14eb5
2 files changed
+173 -2

No files matched your search

+162 -2
View File
@@ -240,7 +240,88 @@ func TestSelectors_ResolveTheSameElementsAsTheWebRuntime(t *testing.T) {
name: "not secure", name: "not secure",
selector: "secure:false", selector: "secure:false",
object: objectSelector("secure", "false"), object: objectSelector("secure", "false"),
want: []string{"login_email", "login_note", "login_terms"}, want: []string{
"login_email", "login_note", "login_terms",
"hint_phone", "hint_search", "hint_amount", "hint_code",
},
},
{
// hintText is the accessible-name ladder both producers derive:
// the field's aria-label, the <label> bound to it, its placeholder,
// then its name. The dump writes what fieldHint (driver.go) answers
// and the web runtime compiled the key to [placeholder="..."], so
// every field whose hint comes from a rung above the placeholder
// resolved against the dump on the goja host and named nothing at
// all here. The key is accepted, so no unknown-key error fires.
name: "hintText from the fields aria-label",
selector: "hintText:login_email",
object: objectSelector("hintText", "login_email"),
want: []string{"login_email"},
},
{
name: "hintText from the label bound to the field",
selector: "hintText:Phone number",
object: objectSelector("hintText", "Phone number"),
want: []string{"hint_phone"},
},
{
name: "hintText from the placeholder",
selector: "hintText:Search customers",
object: objectSelector("hintText", "Search customers"),
want: []string{"hint_search"},
},
{
name: "hintText from the name the form gives the field",
selector: "hintText:verification_code",
object: objectSelector("hintText", "verification_code"),
want: []string{"hint_code"},
},
{
name: "hintText where the aria-label outranks the placeholder",
selector: "hintText:Amount in rupees",
object: objectSelector("hintText", "Amount in rupees"),
want: []string{"hint_amount"},
},
{
// The rung the ladder passed over is not the field's hint, so
// naming it names nothing. Compiling the key to the placeholder
// attribute answered with the field here and with no element on the
// goja host: matching MORE than the spec said is the half of this
// that lands a find on an element nobody wrote.
name: "hintText naming the placeholder the aria-label outranks",
selector: "hintText:0.00",
object: objectSelector("hintText", "0.00"),
want: nil,
},
{
// The ios name for the same ladder, which internal/hierarchy
// aliases onto hintText.
name: "placeholderValue",
selector: "placeholderValue:login_email",
object: objectSelector("placeholderValue", "login_email"),
want: []string{"login_email"},
},
{
name: "placeholderValue naming the placeholder the aria-label outranks",
selector: "placeholderValue:0.00",
object: objectSelector("placeholderValue", "0.00"),
want: nil,
},
{
// placeholder is the attribute the markup writes, on both hosts:
// the dump carries every attribute under the name it was written
// with, so the key reads the same string on either side of the
// wire and says nothing about the ladder above it.
name: "placeholder",
selector: "placeholder:0.00",
object: objectSelector("placeholder", "0.00"),
want: []string{"hint_amount"},
},
{
name: "placeholder on a field labelled by aria-label alone",
selector: "placeholder:login_email",
object: objectSelector("placeholder", "login_email"),
want: nil,
}, },
{ {
// The head subtree renders nothing, so the hierarchy dump drops it // The head subtree renders nothing, so the hierarchy dump drops it
@@ -376,6 +457,7 @@ func TestSelectors_BooleanStatesNameWhatBothProducersReport(t *testing.T) {
selector: "clickable:true", selector: "clickable:true",
want: []string{ want: []string{
"login_email", "login_password", "login_note", "login_remember", "login_email", "login_password", "login_note", "login_remember",
"hint_phone", "hint_search", "hint_amount", "hint_code",
"state_save", "state_cancel", "state_submit", "state_remember", "state_save", "state_cancel", "state_submit", "state_remember",
"state_agree", "state_month", "todo_link", "state_agree", "state_month", "todo_link",
}, },
@@ -455,7 +537,10 @@ func TestSelectors_BooleanStatesNameWhatBothProducersReport(t *testing.T) {
// the same wrong way, as markup attributes nothing carries. // the same wrong way, as markup attributes nothing carries.
name: "editable", name: "editable",
selector: "editable:true", selector: "editable:true",
want: []string{"login_email", "login_password", "login_note", "login_terms"}, want: []string{
"login_email", "login_password", "login_note", "login_terms",
"hint_phone", "hint_search", "hint_amount", "hint_code",
},
}, },
{ {
name: "not editable", name: "not editable",
@@ -931,6 +1016,81 @@ func TestSelectors_DataTestIDNamesTheSameElementInTheDumpAndOverCDP(t *testing.T
} }
} }
// The same third resolver reads a hint selector, and no CSS says what the
// accessible-name ladder says. Building [placeholder="..."] for hintText made
// this resolver name a field whose hint is its aria-label and whose placeholder
// happens to carry the value, which is an element neither matcher names.
// TapSelector runs only where the dump resolved the target to no coordinates at
// all, so a CSS that matches there taps something nobody selected; matching
// nothing fails the step by name instead, the way every other derived key here
// already does. placeholder keeps its CSS: it is the attribute the markup
// writes, which is what all three resolvers read it as.
func TestSelectors_HintSelectorsResolveOverCDPToWhatBothMatchersName(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+"/selector-parity.html", false, nil); err != nil {
t.Fatalf("Launch: %v", err)
}
dump, err := d.Hierarchy(ctx)
if err != nil {
t.Fatalf("Hierarchy: %v", err)
}
tree, err := hierarchy.Parse(dump)
if err != nil {
t.Fatalf("parse hierarchy: %v", err)
}
installSelectorProbe(ctx, t, d)
cases := []struct {
selector string
want []string
}{
{selector: "hintText:0.00", want: nil},
{selector: "placeholderValue:0.00", want: nil},
{selector: "hintText:Amount in rupees", want: nil},
{selector: "placeholder:0.00", want: []string{"hint_amount"}},
}
for _, testCase := range cases {
t.Run(testCase.selector, func(t *testing.T) {
css, isXPath, err := TranslateStringSelector(testCase.selector)
if err != nil {
t.Fatalf("TranslateStringSelector(%q): %v", testCase.selector, err)
}
if isXPath {
t.Fatalf("TranslateStringSelector(%q) returned an XPath, want CSS", testCase.selector)
}
if overCDP := cssIDsOverCDP(ctx, t, d, css); !slices.Equal(overCDP, testCase.want) {
t.Errorf("the CDP selector %q matched %v, want %v", css, overCDP, testCase.want)
}
})
}
// hintText:Amount in rupees names the field on both matchers and on neither
// resolver CDP can express, so the step fails by name rather than tapping
// the field whose placeholder reads 0.00.
const named = "hintText:Amount in rupees"
if ids := selectorIDsFromDump(tree, named); !slices.Equal(ids, []string{"hint_amount"}) {
t.Errorf("the dump matched %v for %s, want [hint_amount]", ids, named)
}
}
// cssIDsOverCDP resolves a CSS selector the way TapSelector does, over CDP
// against the live document, and reads back the ids it matched.
func cssIDsOverCDP(ctx context.Context, t *testing.T, d *Driver, css string) []string {
t.Helper()
var ids []string
script := `Array.from(document.querySelectorAll(` + jsArgument(css) + `)).map((e) => e.id)`
if err := chromedp.Run(d.tabCtx, chromedp.Evaluate(script, &ids)); err != nil {
t.Fatalf("resolve %q over CDP: %v", css, err)
}
return ids
}
func objectSelector(key, value string) hierarchy.Selector { func objectSelector(key, value string) hierarchy.Selector {
return hierarchy.Selector{Filters: []hierarchy.AttrFilter{{Attr: key, Value: value}}} return hierarchy.Selector{Filters: []hierarchy.AttrFilter{{Attr: key, Value: value}}}
} }
+11
View File
@@ -27,6 +27,17 @@
<div id="login_terms" contenteditable="true">terms</div> <div id="login_terms" contenteditable="true">terms</div>
<input id="login_remember" type="checkbox" aria-label="login_remember" /> <input id="login_remember" type="checkbox" aria-label="login_remember" />
</form> </form>
<!-- hintText is the accessible-name ladder rather than one attribute, so
these fields differ one rung at a time: a bound label, a placeholder,
a placeholder an aria-label outranks, and the name the form gives the
field. -->
<div id="hint_row">
<label id="hint_phone_label" for="hint_phone">Phone number</label>
<input id="hint_phone" />
<input id="hint_search" placeholder="Search customers" />
<input id="hint_amount" aria-label="Amount in rupees" placeholder="0.00" />
<input id="hint_code" name="verification_code" />
</div>
<!-- The boolean states are derived from the live element, never written by <!-- The boolean states are derived from the live element, never written by
the markup, so these controls differ one state at a time: a disabled the markup, so these controls differ one state at a time: a disabled
button and an aria-disabled role control, a box ticked by script with button and an aria-disabled role control, a box ticked by script with