test(chrome): resolve the five state selectors on both matchers

the fixture differs one state at a time: a disabled button and an
aria-disabled role control, a box ticked by script with no checked
attribute beside one cleared by script that has it, and a select whose
first option is selected without the markup saying so anywhere.

half the states are asked inside one container, because a state the
whole page has an opinion about answers with most of the document and a
want list nobody can check by reading.
This commit is contained in:
pj committed 2026-08-18 20:43:40 +05:30
1 parent c8ca350115
commit 654c0afd90
2 files changed
+234

No files matched your search

@@ -225,6 +225,211 @@ func TestSelectors_SecureCombinesWithAnotherKey(t *testing.T) {
} }
} }
// The other five boolean states are derived from the live element the same way
// secure is, and were reached the same wrong way: as a markup attribute, which
// builds [clickable="true"] and matches nothing on any page. The key is
// accepted, so no unknown-key error fires, and the worked example in
// docs/manual/spec-language.md, find({testTag: ..., clickable: true}), resolved
// to no element at all on web while resolving against the dump on the goja host.
//
// Half the states are asked inside one container, because a state the whole page
// has an opinion about (everything is enabled, almost nothing is checked)
// answers with most of the document and a want list nobody can check by reading.
func TestSelectors_BooleanStatesNameWhatBothProducersReport(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 {
name string
selector string
scope string
want []string
}{
{
name: "clickable",
selector: "clickable:true",
want: []string{
"login_email", "login_password", "login_note", "login_remember",
"state_save", "state_cancel", "state_submit", "state_remember",
"state_agree", "state_month", "todo_link",
},
},
{
name: "not clickable",
selector: "clickable:false",
scope: "state_row",
want: []string{"state_january", "state_february"},
},
{
// A control that carries no disabled property is marked by
// aria-disabled alone, and both producers read both.
name: "not enabled",
selector: "enabled:false",
want: []string{"state_cancel", "state_submit"},
},
{
name: "enabled",
selector: "enabled:true",
scope: "state_row",
want: []string{
"state_save", "state_remember", "state_agree", "state_month",
"state_january", "state_february",
},
},
{
name: "focused",
selector: "focused:true",
want: []string{"state_save"},
},
{
name: "not focused",
selector: "focused:false",
scope: "state_row",
want: []string{
"state_cancel", "state_submit", "state_remember", "state_agree",
"state_month", "state_january", "state_february",
},
},
{
// state_remember was ticked by script and carries no checked
// attribute; state_agree carries the attribute and was cleared. The
// markup attribute is the page's starting state, so a selector
// reading it names the pair the wrong way round.
name: "checked",
selector: "checked:true",
want: []string{"state_remember"},
},
{
name: "not checked",
selector: "checked:false",
scope: "state_row",
want: []string{
"state_save", "state_cancel", "state_submit", "state_agree",
"state_month", "state_january", "state_february",
},
},
{
// The first option of a select is selected without the markup
// saying so anywhere.
name: "selected",
selector: "selected:true",
want: []string{"state_january"},
},
{
name: "not selected",
selector: "selected:false",
scope: "state_row",
want: []string{
"state_save", "state_cancel", "state_submit", "state_remember",
"state_agree", "state_month", "state_february",
},
},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
key, value, _ := strings.Cut(testCase.selector, ":")
object := objectSelector(key, value)
encoded := objectSelectorJSON(object)
if testCase.scope != "" {
scope := objectSelector("id", testCase.scope)
path := []hierarchy.Selector{scope, object}
encoded = "[" + objectSelectorJSON(scope) + "," + encoded + "]"
if native := selectorIDsFromDumpPath(tree, path); !slices.Equal(
native,
testCase.want,
) {
t.Errorf("the dump matched %v under %s, want %v",
native, testCase.scope, testCase.want)
}
} else {
if native := selectorIDsFromDump(tree, testCase.selector); !slices.Equal(
native,
testCase.want,
) {
t.Errorf("the dump matched %v for %s, want %v",
native, testCase.selector, testCase.want)
}
if web := selectorIDsFromWebRuntime(
ctx, t, d, testCase.selector,
); !slices.Equal(web, testCase.want) {
t.Errorf("the web runtime matched %v for %s, want %v",
web, testCase.selector, testCase.want)
}
if native := selectorIDsFromDumpObject(tree, object); !slices.Equal(
native,
testCase.want,
) {
t.Errorf("the dump object form matched %v, want %v",
native, testCase.want)
}
}
if web := selectorIDsFromWebRuntime(ctx, t, d, encoded); !slices.Equal(
web,
testCase.want,
) {
t.Errorf("the web runtime matched %v for %s, want %v",
web, encoded, testCase.want)
}
})
}
}
// A type selector is valid only at the head of a compound, and a multi-key
// object selector concatenates its parts into one, so {id, tag} built
// '[id="state_month"]select' and querySelectorAll threw a SyntaxError. Which of
// the two a spec got depended on the order its author wrote the keys in.
func TestSelectors_TagCombinesWithAnotherKey(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)
selector := hierarchy.Selector{Filters: []hierarchy.AttrFilter{
{Attr: "id", Value: "state_month"},
{Attr: "tag", Value: "select"},
}}
want := []string{"state_month"}
if native := selectorIDsFromDumpObject(tree, selector); !slices.Equal(native, want) {
t.Errorf("hierarchy matched %v, want %v", native, want)
}
encoded := objectSelectorJSON(selector)
if web := selectorIDsFromWebRuntime(ctx, t, d, encoded); !slices.Equal(web, want) {
t.Errorf("the web runtime matched %v for %s, want %v", web, encoded, want)
}
}
// `text:` is a substring match on text content wherever the spec runs // `text:` is a substring match on text content wherever the spec runs
// (docs/manual/spec-language.md), so a badge reading "Sent ✓" answers to // (docs/manual/spec-language.md), so a badge reading "Sent ✓" answers to
// text:Sent on web the way it already does on Android and iOS, and one reading // text:Sent on web the way it already does on Android and iOS, and one reading
@@ -474,6 +679,14 @@ func objectSelectorJSON(sel hierarchy.Selector) string {
return string(encoded) return string(encoded)
} }
func selectorIDsFromDumpPath(tree *hierarchy.Tree, path []hierarchy.Selector) []string {
var ids []string
for _, node := range tree.FindAllBySelectorPath(path) {
ids = append(ids, node.Element.ResourceID)
}
return ids
}
func selectorIDsFromDumpObject(tree *hierarchy.Tree, sel hierarchy.Selector) []string { func selectorIDsFromDumpObject(tree *hierarchy.Tree, sel hierarchy.Selector) []string {
var ids []string var ids []string
for _, node := range tree.FindAllBySelector(sel) { for _, node := range tree.FindAllBySelector(sel) {
+21
View File
@@ -27,6 +27,27 @@
<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>
<!-- 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
button and an aria-disabled role control, a box ticked by script with
no checked attribute beside one cleared by script that has the
attribute, and a select whose first option is selected without one. -->
<div id="state_row">
<button id="state_save">save</button>
<button id="state_cancel" disabled>cancel</button>
<div id="state_submit" role="button" aria-disabled="true">submit</div>
<input id="state_remember" type="checkbox" aria-label="state_remember" />
<input id="state_agree" type="checkbox" checked aria-label="state_agree" />
<select id="state_month" aria-label="state_month">
<option id="state_january">January</option>
<option id="state_february">February</option>
</select>
</div>
<script id="state_script">
document.getElementById("state_remember").checked = true;
document.getElementById("state_agree").checked = false;
document.getElementById("state_save").focus();
</script>
<todo-app id="todo_app"> <todo-app id="todo_app">
<todo-list id="todo_list"> <todo-list id="todo_list">
<li id="todo_1">Buy milk</li> <li id="todo_1">Buy milk</li>