Class: Unmagic::Browser::Locator

Inherits:
Object
  • Object
show all
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.

Examples:

Locator.build("Sign in", purpose: :button)
Locator.build(css: "#user_email", purpose: :field)

Constant Summary collapse

KINDS =

The strategies a caller can name explicitly. :auto is 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, the name attribute, the id — 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

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(kind:, query:, purpose: :any, exact: false) ⇒ Locator

Returns a new instance of Locator.

Parameters:

  • kind (Symbol)

    one of KINDS

  • query (String)

    what to look for

  • purpose (Symbol) (defaults to: :any)

    one of PURPOSES

  • exact (Boolean) (defaults to: false)

    require a whole-value match

Raises:

  • (ArgumentError)


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

#exactObject (readonly)

Returns the value of attribute exact.



59
60
61
# File 'lib/unmagic/browser/locator.rb', line 59

def exact
  @exact
end

#kindObject (readonly)

Returns the value of attribute kind.



59
60
61
# File 'lib/unmagic/browser/locator.rb', line 59

def kind
  @kind
end

#purposeObject (readonly)

Returns the value of attribute purpose.



59
60
61
# File 'lib/unmagic/browser/locator.rb', line 59

def purpose
  @purpose
end

#queryObject (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.

Parameters:

  • query (String, nil) (defaults to: nil)

    the positional query, resolved by every strategy in turn

  • purpose (Symbol) (defaults to: :any)

    one of PURPOSES

  • exact (Boolean) (defaults to: false)

    require a whole-value match rather than a substring

  • options (Hash)

    at most one of css:, text:, label:, role:

Returns:

Raises:

  • (ArgumentError)

    when no query is given, or more than one strategy is



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, **options)
  named = options.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_sString

How this locator reads in an error message.

Returns:

  • (String)


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.

Parameters:

  • all (Boolean) (defaults to: false)

    collect every match rather than stopping at the first

Returns:

  • (Hash)


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