Skip to content

Commit 4c44503

Browse files
sanketsudakeclaude
andcommitted
feat: --by label — resolve a form control by its visible label text
From the record-engage run: native <select>s (Category/Type) and the Notes textarea had no accessible name — their labels were separate text nodes — so they couldn't be addressed by --by name and needed an eval to find a CSS selector. --by label resolves the control from its visible label: a <label for>/wrapping label, else a label-ish element (span/div/dt/legend/th) whose text matches, then the control after it in its nearest container. Defaults to contains matching; resolves via querySelector (not the a11y tree, so it works on a hidden tab too). Composes with fill/select/click/type/value. Test: <label for> input, wrapping-label textarea, sibling-span -> native select (the Engage pattern), and a sibling-div -> textarea. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent f51abc8 commit 4c44503

3 files changed

Lines changed: 169 additions & 1 deletion

File tree

internal/chrome/cdp.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -564,6 +564,8 @@ func byFor(selector string, q QueryOpts) []chromedp.QueryOption {
564564
return []chromedp.QueryOption{chromedp.ByFunc(axRefQuery(selector))}
565565
case "cell":
566566
return []chromedp.QueryOption{chromedp.ByFunc(cellQuery(selector))}
567+
case "label":
568+
return []chromedp.QueryOption{chromedp.ByFunc(labelQuery(selector, q.Match))}
567569
}
568570
return byOptions(q)
569571
}
@@ -588,6 +590,11 @@ func query(selector string, q QueryOpts) []chromedp.QueryOption {
588590
chromedp.ByFunc(cellQuery(selector)),
589591
waitOption(q.Wait),
590592
}
593+
case "label":
594+
return []chromedp.QueryOption{
595+
chromedp.ByFunc(labelQuery(selector, q.Match)),
596+
waitOption(q.Wait),
597+
}
591598
}
592599
return queryOptions(q)
593600
}
@@ -664,6 +671,91 @@ const cellLocatorJS = `(() => {
664671
return cands[0] || null;
665672
})()`
666673

674+
// labelQuery resolves a FORM CONTROL by its visible label text — for forms whose
675+
// labels are visible to a human but not wired to the control (no `aria-label`, no
676+
// `<label for>`), which would otherwise force an eval to find a CSS selector. It
677+
// matches a `<label for>`/wrapping label, else a label-ish element (span/div/dt/
678+
// legend/th) whose text matches, then returns the control after it in its
679+
// container. match defaults to contains (labels are often verbose).
680+
func labelQuery(label, match string) func(context.Context, *cdp.Node) ([]cdp.NodeID, error) {
681+
return func(ctx context.Context, _ *cdp.Node) ([]cdp.NodeID, error) {
682+
mode := match
683+
if mode == "" {
684+
mode = "contains"
685+
}
686+
labelJSON, _ := json.Marshal(label)
687+
modeJSON, _ := json.Marshal(mode)
688+
expr := fmt.Sprintf(labelLocatorJS, string(labelJSON), string(modeJSON))
689+
res, exc, err := cdpruntime.Evaluate(expr).Do(ctx)
690+
if err != nil {
691+
return nil, err
692+
}
693+
if exc != nil {
694+
return nil, fmt.Errorf("label locator: %s", exc.Text)
695+
}
696+
if res == nil || res.ObjectID == "" {
697+
return nil, nil
698+
}
699+
nid, err := dom.RequestNode(res.ObjectID).Do(ctx)
700+
if err != nil || nid == 0 {
701+
return nil, err
702+
}
703+
return []cdp.NodeID{nid}, nil
704+
}
705+
}
706+
707+
// labelLocatorJS finds the form control described by a visible label. Args: %[1]s
708+
// label JSON, %[2]s match-mode JSON (exact|contains|regex).
709+
const labelLocatorJS = `(() => {
710+
const want = %[1]s, mode = %[2]s;
711+
const norm = s => (s || "").replace(/\s+/g, " ").trim();
712+
const cmp = (a, b) => {
713+
a = norm(a);
714+
if (mode === "exact") return a.toLowerCase() === b.toLowerCase();
715+
if (mode === "regex") { try { return new RegExp(b).test(a); } catch (e) { return false; } }
716+
return a.toLowerCase().includes(b.toLowerCase());
717+
};
718+
const CTL = "input:not([type=hidden]),select,textarea,[contenteditable=true],[role=textbox],[role=combobox],[role=spinbutton],[role=listbox]";
719+
const vis = el => {
720+
const r = el.getBoundingClientRect();
721+
if (r.width < 1 || r.height < 1) return false;
722+
const cs = getComputedStyle(el);
723+
return cs.visibility !== "hidden" && cs.display !== "none";
724+
};
725+
// Given a matching label element, return the control it labels: a control it
726+
// wraps, else the first visible control in the nearest ancestor that has one,
727+
// preferring one that follows the label in document order.
728+
const controlFor = lab => {
729+
let node = lab;
730+
for (let i = 0; i < 4 && node; i++) {
731+
const ctls = [...node.querySelectorAll(CTL)].filter(vis);
732+
if (ctls.length) {
733+
const after = ctls.find(c => lab.compareDocumentPosition(c) & Node.DOCUMENT_POSITION_FOLLOWING);
734+
return after || ctls[0];
735+
}
736+
node = node.parentElement;
737+
}
738+
return null;
739+
};
740+
// 1. A real <label> (for= or wrapping) — the authoritative case.
741+
for (const lab of document.querySelectorAll("label")) {
742+
if (!cmp(lab.textContent, want)) continue;
743+
const f = lab.getAttribute("for");
744+
const ctl = f ? document.getElementById(f) : lab.querySelector(CTL);
745+
if (ctl && vis(ctl)) return ctl;
746+
}
747+
// 2. A label-ish element whose text matches -> the control near it. Prefer the
748+
// tightest (shortest-text) match so "Notes" beats a paragraph containing it.
749+
const labelish = [...document.querySelectorAll("label,span,div,dt,legend,th,p,strong,b")]
750+
.filter(e => e.childElementCount <= 2 && cmp(e.textContent, want));
751+
labelish.sort((a, b) => norm(a.textContent).length - norm(b.textContent).length);
752+
for (const lab of labelish) {
753+
const c = controlFor(lab);
754+
if (c) return c;
755+
}
756+
return null;
757+
})()`
758+
667759
// axRefQuery resolves a snap-issued element ref ("e<backendNodeId>") back to a
668760
// frontend node, so a caller acts on the exact element snap reported without
669761
// re-resolving it by name. The backend id is stable for the document's lifetime.

internal/chrome/label_test.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package chrome
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"net/http"
7+
"net/http/httptest"
8+
"testing"
9+
"time"
10+
)
11+
12+
// --by label resolves a form control by its visible label text, across the
13+
// common patterns: <label for>, a wrapping <label>, and a sibling label element
14+
// next to the control (the Engage case, where the <select> has no accessible name).
15+
func TestLabelAddressing(t *testing.T) {
16+
if testing.Short() {
17+
t.Skip("skipping live-Chrome integration in -short mode")
18+
}
19+
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
20+
defer cancel()
21+
22+
b, err := launch(true, tmpProfile(t), 0)
23+
if err != nil {
24+
t.Skipf("cannot launch a managed headless Chrome here: %v", err)
25+
}
26+
defer b.Close()
27+
28+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
29+
fmt.Fprint(w, `<!doctype html><title>Label</title><body>
30+
<label for="em">Email</label><input id="em">
31+
<label>Comments <textarea id="cm"></textarea></label>
32+
<div class="field"><span>Activity Category</span>
33+
<select id="cat"><option>Select…</option><option>Direct Revenue</option><option>Recruiting</option></select></div>
34+
<div class="field"><div class="lbl">Notes</div><textarea id="nt"></textarea></div>
35+
</body>`)
36+
}))
37+
defer srv.Close()
38+
39+
id := firstTab(ctx, t, b)
40+
if _, err := b.Navigate(ctx, id, srv.URL); err != nil {
41+
t.Fatalf("Navigate: %v", err)
42+
}
43+
44+
// <label for> input.
45+
if _, err := b.Fill(ctx, id, "Email", "a@b.com", QueryOpts{By: "label"}); err != nil {
46+
t.Fatalf("Fill --by label Email: %v", err)
47+
}
48+
if v := evalString(ctx, t, b, id, "document.getElementById('em').value"); v != "a@b.com" {
49+
t.Errorf("Email = %q, want a@b.com", v)
50+
}
51+
52+
// Wrapping <label> around a textarea.
53+
if _, err := b.Fill(ctx, id, "Comments", "hello", QueryOpts{By: "label"}); err != nil {
54+
t.Fatalf("Fill --by label Comments: %v", err)
55+
}
56+
if v := evalString(ctx, t, b, id, "document.getElementById('cm').value"); v != "hello" {
57+
t.Errorf("Comments = %q, want hello", v)
58+
}
59+
60+
// Sibling label next to a native <select> with no accessible name (Engage
61+
// pattern) — resolve it and select an option via `select --by label`.
62+
if _, err := b.Select(ctx, id, "Activity Category", "Direct Revenue", SelectOpts{Query: QueryOpts{By: "label"}}); err != nil {
63+
t.Fatalf("Select --by label Activity Category: %v", err)
64+
}
65+
if v := evalString(ctx, t, b, id, "(() => { const s=document.getElementById('cat'); return s.options[s.selectedIndex].text; })()"); v != "Direct Revenue" {
66+
t.Errorf("Category = %q, want Direct Revenue", v)
67+
}
68+
69+
// Sibling non-<label> element (a div) labelling a textarea.
70+
if _, err := b.Fill(ctx, id, "Notes", "note text", QueryOpts{By: "label"}); err != nil {
71+
t.Fatalf("Fill --by label Notes: %v", err)
72+
}
73+
if v := evalString(ctx, t, b, id, "document.getElementById('nt').value"); v != "note text" {
74+
t.Errorf("Notes = %q, want 'note text'", v)
75+
}
76+
}

internal/cli/commands.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ func (a *App) newRoot() *cobra.Command {
5252
pf.BoolVar(&a.noDaemon, "no-daemon", d.NoDaemon, "connect directly instead of via the shared daemon")
5353
pf.StringVar(&a.profileDir, "profile-dir", d.ProfileDir, "managed-launch Chrome profile dir (else $CHROME_CDP_PROFILE or ~/.cache/chrome-cdp/profile)")
5454
pf.IntVar(&a.port, "port", d.Port, "explicit Chrome debug port to attach to / launch with (0 = auto)")
55-
pf.StringVar(&a.byFlag, "by", d.By, "selector syntax: css|id|search|jspath|css-all|name|ref|cell (name = ARIA accessible name; ref = snap e<id>; cell = grid input by [row|]column header)")
55+
pf.StringVar(&a.byFlag, "by", d.By, "selector syntax: css|id|search|jspath|css-all|name|ref|cell|label (name = ARIA accessible name; ref = snap e<id>; cell = grid input by [row|]column header; label = form control by visible label text)")
5656
pf.StringVar(&a.waitFlag, "wait", d.Wait, "selector wait condition: visible|ready|enabled")
5757
pf.StringVar(&a.roleFlag, "role", "", "with --by name: constrain to an ARIA role (button|link|textbox|…)")
5858
pf.IntVar(&a.nthFlag, "nth", 0, "with --by name: pick the Nth (1-based) match among visible candidates")

0 commit comments

Comments
 (0)