@@ -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.
0 commit comments