From 6d03c5788a0b7f2491d7710bb333d0558d2d403a Mon Sep 17 00:00:00 2001 From: PJ Date: Sat, 15 Aug 2026 13:59:17 +0530 Subject: [PATCH] fix(chrome): emit data-testid so both resolvers name the same element The V8 host names a web target by data-testid and TapSelector translates that selector into a CSS attribute match, but the dump carried no such attribute and no alias could supply one, since an alias only redirects to a key that already holds the value. tree.Find was therefore always nil for exactly the selectors examples/folio-web tags with. Claude-Session: https://claude.ai/code/session_01A5KmftdEJ49A9z5mF5ESrX --- internal/driver/chrome/driver.go | 6 ++ .../driver/chrome/selector_parity_test.go | 56 +++++++++++++++++++ .../chrome/testdata/selector-parity.html | 6 +- 3 files changed, 65 insertions(+), 3 deletions(-) diff --git a/internal/driver/chrome/driver.go b/internal/driver/chrome/driver.go index a220f03..e155de9 100644 --- a/internal/driver/chrome/driver.go +++ b/internal/driver/chrome/driver.go @@ -456,6 +456,12 @@ func (d *Driver) Hierarchy(ctx context.Context) (string, error) { const text = (el.textContent || '').trim().slice(0, 200); if (text) attrs.text = text; if (el.id) attrs['resource-id'] = el.id; + // The V8 host names a target by data-testid (IDENTITY_KEYS in + // pkg/spec/src/web-runtime.ts) and TapSelector translates the selector into + // a CSS attribute match, so a dump without this attribute leaves the goja + // host unable to resolve a target the other two resolve fine. + const testid = el.getAttribute('data-testid'); + if (testid) attrs['data-testid'] = testid; const label = el.getAttribute('aria-label') || el.getAttribute('alt') || el.getAttribute('title') || ''; if (label) attrs['content-desc'] = label; const tag = (el.tagName || '').toLowerCase(); diff --git a/internal/driver/chrome/selector_parity_test.go b/internal/driver/chrome/selector_parity_test.go index c6e3473..23272e2 100644 --- a/internal/driver/chrome/selector_parity_test.go +++ b/internal/driver/chrome/selector_parity_test.go @@ -96,6 +96,12 @@ func TestSelectors_ResolveTheSameElementsAsTheWebRuntime(t *testing.T) { object: objectSelector("desc", "customer_row_a1"), want: []string{"customer_row_a1"}, }, + { + name: "data-testid", + selector: "data-testid:customer-row", + object: objectSelector("data-testid", "customer-row"), + want: []string{"customer_row_a1", "customer_row_b2"}, + }, } for _, testCase := range cases { t.Run(testCase.name, func(t *testing.T) { @@ -123,6 +129,56 @@ func TestSelectors_ResolveTheSameElementsAsTheWebRuntime(t *testing.T) { } } +// A third resolver reads the same selector: TapSelector hands it to CDP as the +// CSS TranslateStringSelector builds. The runner uses both within one InputText +// step, resolving the target in the dump and tapping it over CDP, so a selector +// the two read differently taps one element and reads the text of another. +func TestSelectors_DataTestIDNamesTheSameElementInTheDumpAndOverCDP(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) + } + + const selector = "data-testid:summary" + element := tree.Find(selector) + if element == nil { + t.Fatalf("the dump resolves %s to nothing, so every step that names a target this way loses it", selector) + } + css, isXPath, err := TranslateStringSelector(selector) + if err != nil { + t.Fatalf("TranslateStringSelector(%q): %v", selector, err) + } + if isXPath { + t.Fatalf("TranslateStringSelector(%q) returned an XPath, want CSS", selector) + } + var overCDP string + script := `(document.querySelector(` + jsArgument(css) + `) || {}).id || ""` + if err := chromedp.Run(d.tabCtx, chromedp.Evaluate(script, &overCDP)); err != nil { + t.Fatalf("resolve %q over CDP: %v", css, err) + } + if overCDP == "" { + t.Fatalf("the CDP selector %q matched nothing", css) + } + if element.ResourceID != overCDP { + t.Errorf("the dump resolves %s to %q, the CDP selector %q to %q", + selector, element.ResourceID, css, overCDP) + } +} + func objectSelector(key, value string) hierarchy.Selector { return hierarchy.Selector{Filters: []hierarchy.AttrFilter{{Attr: key, Value: value}}} } diff --git a/internal/driver/chrome/testdata/selector-parity.html b/internal/driver/chrome/testdata/selector-parity.html index 2892f46..cd40755 100644 --- a/internal/driver/chrome/testdata/selector-parity.html +++ b/internal/driver/chrome/testdata/selector-parity.html @@ -5,10 +5,10 @@ selector parity -
summary
+
summary
-
Alice
-
Bob
+
Alice
+
Bob
Carol