fix(spec): a hint selector names the ladder both producers derive

hintText and placeholderValue are the accessible-name ladder, derived
from the live element, and compiling them to [placeholder="..."] made
them name the wrong field or none at all. A field labelled by an
aria-label or a bound <label> carries no placeholder, so it resolved
against the dump on the goja host and reached nothing here; one carrying
both answered to its placeholder here where the dump answers to its
aria-label, which lands a find on an element nobody named.

Both keys read the same fieldHint elementHandle and the hierarchy dump
(internal/driver/chrome/driver.go) derive the fact with, so a selector
cannot name a field this host calls something else. An empty hint names
nothing rather than everything that is no field: both producers write
the fact only where the ladder answered.

placeholder stays the attribute the markup writes, which is what the
dump carries under that name too, so a field whose hint is something
else still answers to it on both hosts.
This commit is contained in:
pj committed 2026-08-19 10:36:57 +05:30
1 parent 01aa365d0e
commit 6e551e9559
2 files changed
+78 -2

No files matched your search

+21 -2
View File
@@ -129,9 +129,10 @@ const KNOWN_KEY_TO_CSS: Record<string, (value: string) => string> = {
// The name the ios sidecar writes the label under, and the canonical key
// internal/hierarchy resolves the whole family through.
accessibilityText: (v) => `[aria-label="${cssEscape(v)}"]`,
// The attribute the markup writes, which is what the hierarchy dump carries
// under this name too. It says nothing about the ladder hintText climbs: a
// field whose placeholder an aria-label outranks still answers here.
placeholder: (v) => `[placeholder="${cssEscape(v)}"]`,
placeholderValue: (v) => `[placeholder="${cssEscape(v)}"]`,
hintText: (v) => `[placeholder="${cssEscape(v)}"]`,
secure: secureSelector,
};
@@ -179,8 +180,26 @@ const KNOWN_KEY_TO_STATE: Record<
selected: stateMatcher(isSelected),
editable: stateMatcher(isEditable),
scrollable: scrollableMatcher,
hintText: hintMatcher,
placeholderValue: hintMatcher,
};
// hintText is the accessible-name ladder, derived from the live element, and
// compiling it to [placeholder="..."] made it name the wrong field or none: a
// field labelled by an aria-label or a bound <label> carries no placeholder at
// all, so it resolved against the dump on the goja host and reached nothing
// here, and one carrying both answered to the placeholder here where the dump
// answers to the aria-label. It reads the same fieldHint elementHandle and the
// hierarchy dump (internal/driver/chrome/driver.go) derive the fact with, so a
// selector cannot name a field this host calls something else.
//
// An empty hint names nothing rather than everything that is no field: both
// producers write the attribute only where the ladder answered.
function hintMatcher(value: string): (element: Element) => boolean {
if (value === "") return () => false;
return (element) => fieldHint(element) === value;
}
// A value that is neither true nor false can match nothing, the way a CSS part
// built from one resolves to `:not(*)`.
function stateMatcher(
+57
View File
@@ -1063,6 +1063,63 @@ test("scrollable selects the containers this host offers a scroll on", () => {
});
});
// hintText is the accessible-name ladder, not one attribute, and compiling it
// to [placeholder="..."] reached the wrong field or none: the fields labelled
// from a rung above the placeholder resolved against the dump on the goja host
// and named nothing here, and the one carrying both answered to its placeholder
// here where the dump answers to its aria-label. Matching MORE than the spec
// said is the half that lands a find on an element nobody wrote.
const hintFields = (): FakeElementSpec[] => [
{ tag: "input", x: 0, y: 0, width: 120, height: 20, id: "email", label: "Email" },
{
tag: "input", x: 0, y: 20, width: 120, height: 20, id: "search",
attrs: { placeholder: "Search customers" },
},
{
tag: "input", x: 0, y: 40, width: 120, height: 20, id: "amount",
label: "Amount in rupees", attrs: { placeholder: "0.00" },
},
{
tag: "input", x: 0, y: 60, width: 120, height: 20, id: "code",
attrs: { name: "verification_code" },
},
{ tag: "h1", x: 0, y: 80, width: 120, height: 20, id: "title", label: "Email" },
];
test("hintText selects the field by the name this host derives for it", () => {
withFakeDocument(hintFields().map(fakeElement), () => {
assert.deepEqual(matchedIDs({ hintText: "Email" }), ["email"]);
assert.deepEqual(matchedIDs("hintText:Email"), ["email"]);
assert.deepEqual(matchedIDs({ hintText: "Search customers" }), ["search"]);
assert.deepEqual(matchedIDs({ hintText: "Amount in rupees" }), ["amount"]);
assert.deepEqual(matchedIDs({ hintText: "verification_code" }), ["code"]);
// The rung the ladder passed over is not the field's hint, and a heading
// is no field at all, so neither answers.
assert.deepEqual(matchedIDs({ hintText: "0.00" }), []);
// Both producers write the fact only where the ladder answered, so an empty
// hint names nothing rather than everything that is no field.
assert.deepEqual(matchedIDs({ hintText: "" }), []);
});
});
test("placeholderValue names the ladder hintText names", () => {
withFakeDocument(hintFields().map(fakeElement), () => {
assert.deepEqual(matchedIDs({ placeholderValue: "Email" }), ["email"]);
assert.deepEqual(matchedIDs({ placeholderValue: "Amount in rupees" }), ["amount"]);
assert.deepEqual(matchedIDs({ placeholderValue: "0.00" }), []);
});
});
// placeholder stays the attribute the markup writes, which is what the dump
// carries under that name too, so the two hosts read the same string for it.
test("placeholder names the attribute the markup writes", () => {
withFakeDocument(hintFields().map(fakeElement), () => {
assert.deepEqual(matchedIDs({ placeholder: "0.00" }), ["amount"]);
assert.deepEqual(matchedIDs({ placeholder: "Search customers" }), ["search"]);
assert.deepEqual(matchedIDs({ placeholder: "Email" }), []);
});
});
test("attrs carries every other attribute alongside tag and aria-label", () => {
const attrs = attrsOf(
domElement({ tag: "input", attributes: { id: "txn-note", placeholder: "What's this for?" } }),