From e2f986420107db3b03b7784beff8e3c0b8726ccf Mon Sep 17 00:00:00 2001 From: PJ Date: Tue, 18 Aug 2026 20:16:03 +0530 Subject: [PATCH] fix(spec): keep a secure selector valid beside another key a multi-key object selector concatenates its parts into one compound, and a type selector is valid only at the head of one, so {id, secure} built '[id="pwd"]input[type="password"]' and querySelectorAll threw. --- .../driver/chrome/selector_parity_test.go | 40 +++++++++++++++++++ pkg/spec/src/web-runtime.ts | 7 +++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/internal/driver/chrome/selector_parity_test.go b/internal/driver/chrome/selector_parity_test.go index 3512628..2559cad 100644 --- a/internal/driver/chrome/selector_parity_test.go +++ b/internal/driver/chrome/selector_parity_test.go @@ -185,6 +185,46 @@ func TestSelectors_ResolveTheSameElementsAsTheWebRuntime(t *testing.T) { } } +// A multi-key object selector concatenates its parts into ONE compound CSS +// selector, and a type selector is valid only at the head of a compound. secure +// resolves to a type selector, so pairing it with any key that sorts before it +// turned the whole selector into a parse error: querySelectorAll throws, and +// what a spec sees is an exception out of the extractor rather than an element. +func TestSelectors_SecureCombinesWithAnotherKey(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: "login_password"}, + {Attr: "secure", Value: "true"}, + }} + want := []string{"login_password"} + 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 // (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 diff --git a/pkg/spec/src/web-runtime.ts b/pkg/spec/src/web-runtime.ts index 53195a4..b705e42 100644 --- a/pkg/spec/src/web-runtime.ts +++ b/pkg/spec/src/web-runtime.ts @@ -129,8 +129,13 @@ const KNOWN_KEY_TO_CSS: Record string> = { // call editable, so false is every editable field that is NOT a password entry // rather than everything that is not one: an element that is no field reports // null, as android reports null for everything, and answers to neither value. +// +// Both arms are wrapped in `:is()` because a multi-key selector concatenates the +// parts into one compound, where a type selector is valid only at the head: +// `{id, secure}` built `[id="pwd"]input[type="password"]`, which is a parse +// error, and querySelectorAll throws rather than answering with nothing. function secureSelector(value: string): string { - if (value === "true") return `input[type="password"]`; + if (value === "true") return `:is(input[type="password"])`; if (value !== "false") return ":not(*)"; const textInput = ["password", ...NON_TEXT_INPUT_TYPES] .map((type) => `:not([type="${type}"])`)