Class: Unmagic::Browser::Locator
- Inherits:
-
Object
- Object
- Unmagic::Browser::Locator
- Defined in:
- lib/unmagic/browser/locator.rb
Overview
How a string like "Sign in" turns into an element on the page.
A locator is a description of what to look for, not a lookup — it's the
spec handed to the JavaScript matcher that runs in the page. A plain
string is resolved by label, then visible text, then ARIA role, falling
back to CSS, which is what keeps the API usable by a caller (an LLM, say)
that has never seen the page's markup. Naming a strategy explicitly —
css:, text:, label:, role: — skips straight to it.
Constant Summary collapse
- KINDS =
The strategies a caller can name explicitly.
:autois the default and tries the others in turn. [:auto, :css, :text, :label, :role].freeze
- PURPOSES =
What kind of element a verb is after. Narrows the candidate set before any matching happens, so
click_button "Go"can't land on a paragraph that happens to read "Go". [:any, :button, :link, :field, :checkbox, :radio, :select, :file_field].freeze
- SCRIPT =
The matcher, run in the page. Takes a spec and an optional scope element and returns the first match, an array of matches, or null.
It works in strategies, tried in order until one produces something: exact name match, then substring, then role, then CSS. "Name" is deliberately broad — an ARIA label, an associated
<label>, a placeholder, a title, the visible text, thenameattribute, theid— because the caller usually knows what the field is called, not how it's marked up. <<~JAVASCRIPT (spec, scope) => { const root = scope || document; const doc = root.ownerDocument || document; const norm = (value) => (value || "").replace(/\\s+/g, " ").trim(); const visible = (el) => { if (!el || el.nodeType !== 1) return false; if (el.hidden) return false; const style = getComputedStyle(el); if (style.visibility === "hidden" || style.display === "none") return false; return el.getClientRects().length > 0; }; const PURPOSES = { any: "*", button: "button, input[type=submit], input[type=button], input[type=reset], " + "input[type=image], [role=button], summary", link: "a[href], [role=link]", field: "input:not([type=submit]):not([type=button]):not([type=reset]):not([type=image])" + ":not([type=checkbox]):not([type=radio]):not([type=file]):not([type=hidden]), " + "textarea, [contenteditable=''], [contenteditable='true']", checkbox: "input[type=checkbox], [role=checkbox], [role=switch]", radio: "input[type=radio], [role=radio]", select: "select, [role=combobox], [role=listbox]", file_field: "input[type=file]", }; const textOf = (el) => { const tag = el.tagName; if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return ""; return norm(el.innerText === undefined ? el.textContent : el.innerText); }; const labelsOf = (el) => { const out = []; const aria = el.getAttribute("aria-label"); if (aria) out.push(aria); const by = el.getAttribute("aria-labelledby"); if (by) { for (const id of by.split(/\\s+/)) { const target = doc.getElementById(id); if (target) out.push(target.textContent); } } if (el.labels && el.labels.length) { for (const label of el.labels) out.push(label.textContent); } else if (el.closest) { const ancestor = el.closest("label"); if (ancestor) out.push(ancestor.textContent); } for (const attribute of ["placeholder", "title", "alt"]) { const value = el.getAttribute(attribute); if (value) out.push(value); } return out; }; const identifiersOf = (el) => { const out = []; const type = (el.getAttribute("type") || "").toLowerCase(); if (el.tagName === "INPUT" && ["submit", "button", "reset"].includes(type)) { const value = el.getAttribute("value"); if (value) out.push(value); } const name = el.getAttribute("name"); if (name) out.push(name); if (el.id) out.push(el.id); return out; }; const roleOf = (el) => { const explicit = el.getAttribute("role"); if (explicit) return explicit.trim().toLowerCase(); const tag = el.tagName.toLowerCase(); const type = (el.getAttribute("type") || "text").toLowerCase(); if (tag === "button") return "button"; if (tag === "a") return el.hasAttribute("href") ? "link" : null; if (tag === "select") return "combobox"; if (tag === "textarea") return "textbox"; if (tag === "img") return "img"; if (tag === "input") { if (["submit", "button", "reset", "image"].includes(type)) return "button"; if (type === "checkbox") return "checkbox"; if (type === "radio") return "radio"; if (type === "search") return "searchbox"; return "textbox"; } if (/^h[1-6]$/.test(tag)) return "heading"; return null; }; const namesOf = (el, kind) => { if (kind === "text") return [textOf(el)]; if (kind === "label") return labelsOf(el); return labelsOf(el).concat([textOf(el)]).concat(identifiersOf(el)); }; const select = (selector) => { try { return Array.from(root.querySelectorAll(selector)); } catch (error) { return []; } }; const needle = norm(spec.query).toLowerCase(); const purpose = PURPOSES[spec.purpose] || PURPOSES.any; const results = []; const collect = (elements, matches) => { for (const el of elements) { if (results.indexOf(el) !== -1) continue; if (!visible(el)) continue; if (matches(el)) results.push(el); } }; const byName = (exact) => (el) => namesOf(el, spec.kind).some((name) => { const value = norm(name).toLowerCase(); if (!value || !needle) return false; return exact ? value === needle : value.indexOf(needle) !== -1; }); const byRole = (el) => roleOf(el) === needle; const byCss = () => collect(select(spec.query), () => true); const strategies = []; if (spec.kind === "css") { strategies.push(byCss); } else if (spec.kind === "role") { strategies.push(() => collect(select(purpose), byRole)); } else { // A query that opens like a CSS selector is one: nothing on a page is // labelled ".product", so trying names first would only waste a pass. if (spec.kind === "auto" && /^[.#\\[]/.test(spec.query)) strategies.push(byCss); strategies.push(() => collect(select(purpose), byName(true))); if (!spec.exact) strategies.push(() => collect(select(purpose), byName(false))); if (spec.kind === "auto") { strategies.push(() => collect(select(purpose), byRole)); strategies.push(byCss); } } for (const strategy of strategies) { strategy(); if (results.length) break; } return spec.all ? results : (results[0] || null); } JAVASCRIPT
Instance Attribute Summary collapse
-
#exact ⇒ Object
readonly
Returns the value of attribute exact.
-
#kind ⇒ Object
readonly
Returns the value of attribute kind.
-
#purpose ⇒ Object
readonly
Returns the value of attribute purpose.
-
#query ⇒ Object
readonly
Returns the value of attribute query.
Class Method Summary collapse
-
.build(query = nil, purpose: :any, exact: false, **options) ⇒ Locator
Build a locator from a verb's arguments.
Instance Method Summary collapse
-
#initialize(kind:, query:, purpose: :any, exact: false) ⇒ Locator
constructor
A new instance of Locator.
-
#to_s ⇒ String
How this locator reads in an error message.
-
#to_spec(all: false) ⇒ Hash
The spec handed to SCRIPT in the page.
Constructor Details
#initialize(kind:, query:, purpose: :any, exact: false) ⇒ Locator
Returns a new instance of Locator.
65 66 67 68 69 70 71 72 73 74 |
# File 'lib/unmagic/browser/locator.rb', line 65 def initialize(kind:, query:, purpose: :any, exact: false) raise ArgumentError, "unknown locator kind #{kind.inspect}" unless KINDS.include?(kind) raise ArgumentError, "unknown locator purpose #{purpose.inspect}" unless PURPOSES.include?(purpose) @kind = kind @query = query.to_s @purpose = purpose @exact = exact freeze end |
Instance Attribute Details
#exact ⇒ Object (readonly)
Returns the value of attribute exact.
59 60 61 |
# File 'lib/unmagic/browser/locator.rb', line 59 def exact @exact end |
#kind ⇒ Object (readonly)
Returns the value of attribute kind.
59 60 61 |
# File 'lib/unmagic/browser/locator.rb', line 59 def kind @kind end |
#purpose ⇒ Object (readonly)
Returns the value of attribute purpose.
59 60 61 |
# File 'lib/unmagic/browser/locator.rb', line 59 def purpose @purpose end |
#query ⇒ Object (readonly)
Returns the value of attribute query.
59 60 61 |
# File 'lib/unmagic/browser/locator.rb', line 59 def query @query end |
Class Method Details
.build(query = nil, purpose: :any, exact: false, **options) ⇒ Locator
Build a locator from a verb's arguments.
38 39 40 41 42 43 44 45 46 47 48 49 50 |
# File 'lib/unmagic/browser/locator.rb', line 38 def build(query = nil, purpose: :any, exact: false, **) named = .slice(*KINDS - [:auto]) raise ArgumentError, "give a query as a string or as one of #{named_list}" if query.nil? && named.empty? raise ArgumentError, "give either a query or one of #{named_list}, not both" if query && named.any? raise ArgumentError, "give only one of #{named_list}" if named.size > 1 if query new(kind: :auto, query: query, purpose: purpose, exact: exact) else kind, value = named.first new(kind: kind, query: value, purpose: purpose, exact: exact) end end |
Instance Method Details
#to_s ⇒ String
How this locator reads in an error message.
87 88 89 90 |
# File 'lib/unmagic/browser/locator.rb', line 87 def to_s subject = @purpose == :any ? "element" : @purpose.to_s.tr("_", " ") @kind == :auto ? "#{subject} matching #{@query.inspect}" : "#{subject} with #{@kind} #{@query.inspect}" end |
#to_spec(all: false) ⇒ Hash
The spec handed to SCRIPT in the page.
80 81 82 |
# File 'lib/unmagic/browser/locator.rb', line 80 def to_spec(all: false) { kind: @kind.to_s, query: @query, purpose: @purpose.to_s, exact: @exact, all: all } end |