mirror of
https://github.com/Skyvern-AI/skyvern.git
synced 2026-04-28 03:30:10 +00:00
184 lines
7 KiB
Python
184 lines
7 KiB
Python
"""Tests for compute_stable_selector, compute_selector_options, and _looks_dynamic."""
|
|
|
|
from skyvern.utils.css_selector import _looks_dynamic, compute_selector_options, compute_stable_selector
|
|
|
|
|
|
class TestLooksDynamic:
|
|
"""Tests for the _looks_dynamic heuristic."""
|
|
|
|
def test_long_hex_ids_are_dynamic(self):
|
|
assert _looks_dynamic("ember12345678") is True
|
|
assert _looks_dynamic("react-abcdef01") is True
|
|
|
|
def test_word_digit_ids_are_dynamic(self):
|
|
assert _looks_dynamic("uid-12345") is True
|
|
assert _looks_dynamic("el_56789") is True
|
|
|
|
def test_semantic_ids_are_not_dynamic(self):
|
|
"""Meaningful IDs should NOT be flagged as dynamic."""
|
|
assert _looks_dynamic("username") is False
|
|
assert _looks_dynamic("sign_in") is False
|
|
assert _looks_dynamic("passwd") is False
|
|
assert _looks_dynamic("inputUsername") is False
|
|
assert _looks_dynamic("login-form") is False
|
|
assert _looks_dynamic("signOnButton") is False
|
|
|
|
def test_single_char_ids_not_dynamic(self):
|
|
"""Very short IDs shouldn't match the all-caps pattern."""
|
|
assert _looks_dynamic("A") is False
|
|
assert _looks_dynamic("AB") is False
|
|
|
|
def test_title_case_ids_not_dynamic(self):
|
|
"""Title-case or mixed-case IDs are likely meaningful, not generated."""
|
|
assert _looks_dynamic("Login") is False
|
|
assert _looks_dynamic("Form") is False
|
|
assert _looks_dynamic("Nav") is False
|
|
assert _looks_dynamic("Input") is False
|
|
assert _looks_dynamic("Button") is False
|
|
|
|
|
|
class TestComputeStableSelector:
|
|
"""Tests for compute_stable_selector — returns top-ranked option."""
|
|
|
|
def test_prefers_name_over_dynamic_id(self):
|
|
"""When ID is dynamic and name is available, use name."""
|
|
elem = {
|
|
"tagName": "input",
|
|
"text": "",
|
|
"attributes": {"id": "input61", "name": "credentials.passcode", "type": "password"},
|
|
}
|
|
result = compute_stable_selector(elem)
|
|
assert result == 'input[name="credentials.passcode"]'
|
|
|
|
def test_static_id_still_returned_when_no_semantic(self):
|
|
"""When only a stable ID is available, it's returned."""
|
|
elem = {
|
|
"tagName": "div",
|
|
"text": "",
|
|
"attributes": {"id": "signOnButton"},
|
|
}
|
|
result = compute_stable_selector(elem)
|
|
assert result == "#signOnButton"
|
|
|
|
def test_aria_label_ranked_above_id(self):
|
|
"""aria-label is preferred over even stable IDs."""
|
|
elem = {
|
|
"tagName": "input",
|
|
"text": "",
|
|
"attributes": {"aria-label": "Email Address", "id": "email-field"},
|
|
}
|
|
result = compute_stable_selector(elem)
|
|
assert result == 'input[aria-label="Email Address"]'
|
|
|
|
def test_no_data_returns_none(self):
|
|
assert compute_stable_selector(None) is None
|
|
assert compute_stable_selector({}) is None
|
|
|
|
|
|
class TestComputeSelectorOptions:
|
|
"""Tests for compute_selector_options — returns ALL viable selectors ranked."""
|
|
|
|
def test_returns_all_options_ranked(self):
|
|
"""Element with multiple attributes produces multiple options."""
|
|
elem = {
|
|
"tagName": "input",
|
|
"text": "",
|
|
"attributes": {
|
|
"name": "credentials.passcode",
|
|
"id": "input61",
|
|
"placeholder": "Enter passcode",
|
|
"type": "password",
|
|
},
|
|
}
|
|
options = compute_selector_options(elem)
|
|
selectors = [s for s, _ in options]
|
|
|
|
# name should be ranked above the ID (input61 is not caught by _looks_dynamic
|
|
# but name is ranked higher than IDs regardless)
|
|
assert 'input[name="credentials.passcode"]' in selectors
|
|
assert "#input61" in selectors
|
|
assert 'input[placeholder="Enter passcode"]' in selectors
|
|
assert 'input[type="password"]' in selectors
|
|
|
|
# name should come before ID in the ranking
|
|
name_idx = selectors.index('input[name="credentials.passcode"]')
|
|
id_idx = selectors.index("#input61")
|
|
assert name_idx < id_idx
|
|
|
|
def test_dynamic_id_marked_as_possibly_generated(self):
|
|
"""IDs that look auto-generated are included but flagged."""
|
|
elem = {
|
|
"tagName": "button",
|
|
"text": "Submit",
|
|
"attributes": {"id": "ember12345678", "type": "submit"},
|
|
}
|
|
options = compute_selector_options(elem)
|
|
id_options = [(s, d) for s, d in options if s == "#ember12345678"]
|
|
assert len(id_options) == 1
|
|
assert "auto-generated" in id_options[0][1]
|
|
|
|
def test_stable_id_not_marked_dynamic(self):
|
|
"""Stable IDs get a clean description."""
|
|
elem = {
|
|
"tagName": "button",
|
|
"text": "Sign On",
|
|
"attributes": {"id": "signOnButton"},
|
|
}
|
|
options = compute_selector_options(elem)
|
|
id_options = [(s, d) for s, d in options if s == "#signOnButton"]
|
|
assert len(id_options) == 1
|
|
assert "auto-generated" not in id_options[0][1]
|
|
assert id_options[0][1] == "HTML id"
|
|
|
|
def test_data_testid_ranked_first(self):
|
|
"""data-testid is the most stable selector."""
|
|
elem = {
|
|
"tagName": "input",
|
|
"text": "",
|
|
"attributes": {"data-testid": "email-input", "name": "email", "id": "field-1"},
|
|
}
|
|
options = compute_selector_options(elem)
|
|
assert options[0] == ('[data-testid="email-input"]', "stable test attribute")
|
|
|
|
def test_empty_element_returns_empty(self):
|
|
assert compute_selector_options(None) == []
|
|
assert compute_selector_options({}) == []
|
|
|
|
def test_single_option_element(self):
|
|
"""Element with only one viable attribute produces one option."""
|
|
elem = {
|
|
"tagName": "div",
|
|
"text": "",
|
|
"attributes": {"role": "dialog"},
|
|
}
|
|
options = compute_selector_options(elem)
|
|
assert len(options) == 1
|
|
assert options[0] == ('div[role="dialog"]', "ARIA role")
|
|
|
|
def test_button_text_included(self):
|
|
"""Buttons with short text get a :has-text() option."""
|
|
elem = {
|
|
"tagName": "button",
|
|
"text": "Apply Filters",
|
|
"attributes": {},
|
|
}
|
|
options = compute_selector_options(elem)
|
|
assert ('button:has-text("Apply Filters")', "visible text") in options
|
|
|
|
def test_input61_case(self):
|
|
"""The exact case that motivated this change — input61 with a name attribute."""
|
|
elem = {
|
|
"tagName": "input",
|
|
"text": "",
|
|
"attributes": {
|
|
"id": "input61",
|
|
"name": "credentials.passcode",
|
|
"type": "password",
|
|
},
|
|
}
|
|
options = compute_selector_options(elem)
|
|
# Should have at least name, id, and type options
|
|
assert len(options) >= 3
|
|
# Name should be #1 (ranked above ID)
|
|
assert options[0][0] == 'input[name="credentials.passcode"]'
|
|
assert options[0][1] == "form element name"
|