Skip to content

Commit

Permalink
Update role based query engine to work with implicits
Browse files Browse the repository at this point in the history
Make sure that the selector engine works with the implicit roles too.
  • Loading branch information
ankur22 committed Dec 4, 2024
1 parent d845cf0 commit 6e77aaa
Show file tree
Hide file tree
Showing 2 changed files with 158 additions and 53 deletions.
73 changes: 60 additions & 13 deletions common/js/injected_script.js
Original file line number Diff line number Diff line change
Expand Up @@ -157,36 +157,83 @@ class XPathQueryEngine {

class RoleQueryEngine {
queryAll(root, selector) {
const [role, ...nameParts] = selector.split("[name=");
const name = nameParts.length > 0 ? nameParts.join("[name=").slice(0, -1) : null;
const [role, ...filters] = selector.split("[");
const filterOptions = this.parseFilters(filters.join("["));

// Get all elements with the specified role
const matchingElements = Array.from(root.querySelectorAll(`[role="${role}"]`));
// Get all elements matching the role or implicit role
const matchingElements = Array.from(root.querySelectorAll(`[role="${role}"]`))
.concat(this.getImplicitRoleElements(root, role));

// Filter elements by accessible name if provided
if (name) {
return matchingElements.filter((el) => this.getAccessibleName(el) === name);
}
// Filter elements by accessible name and other filters
return matchingElements.filter((el) => this.matchFilters(el, filterOptions));
}

return matchingElements;
// Parse filters like `name=Submit`, `level=2`, etc.
parseFilters(filterString) {
const filters = {};
const regex = /(\w+)=(\w+)/g;
let match;
while ((match = regex.exec(filterString)) !== null) {
filters[match[1]] = match[2];
}
return filters;
}

// Utility to compute the accessible name of an element
// Compute the accessible name of an element
getAccessibleName(element) {
// Prefer aria-label or aria-labelledby
if (element.hasAttribute("aria-label")) {
return element.getAttribute("aria-label");
}

if (element.hasAttribute("aria-labelledby")) {
const labelId = element.getAttribute("aria-labelledby");
const labelElement = element.ownerDocument.getElementById(labelId);
return labelElement ? labelElement.textContent.trim() : "";
}

// Use innerText or textContent as a fallback
// Use innerText as a fallback
return element.textContent.trim();
}

// Get elements with implicit roles (e.g., <button> -> role="button")
getImplicitRoleElements(root, role) {
const implicitRoles = {
button: ["button", "input[type='button']", "input[type='submit']", "input[type='reset']"],
link: ["a[href]"],
checkbox: ["input[type='checkbox']"],
heading: ["h1", "h2", "h3", "h4", "h5", "h6"],
dialog: ["dialog"],
img: ["img[alt]"],
form: ["form"],
textbox: ["input[type='text']", "textarea"],
radio: ["input[type='radio']"],
// Add more implicit roles as needed
};

if (!implicitRoles[role]) return [];
return implicitRoles[role]
.map((selector) => Array.from(root.querySelectorAll(selector)))
.flat();
}

// Match element filters (e.g., name, level, pressed)
matchFilters(element, filters) {
if (filters.name && this.getAccessibleName(element) !== filters.name) {
return false;
}
if (filters.level) {
const headingLevel = parseInt(element.tagName.replace("H", ""), 10);
if (isNaN(headingLevel) || headingLevel !== parseInt(filters.level, 10)) {
return false;
}
}
if (filters.pressed) {
const ariaPressed = element.getAttribute("aria-pressed");
if (ariaPressed !== filters.pressed) {
return false;
}
}
return true;
}
}


Expand Down
138 changes: 98 additions & 40 deletions common/js/selector_engine.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,100 @@
(() => {
// Selector Finder Function
function findBestSelector(element) {
// 1. Check for `data-testid`
if (element.hasAttribute('data-testid')) {
return `[data-testid="${element.getAttribute('data-testid')}"]`;
}

// 2. Check for `id`
if (element.id) {
return `#${element.id}`;
}

// 3. Check for role and accessible name (explicit or implicit roles)
const role = getRole(element);
if (role) {
const name = getAccessibleName(element);
if (name) {
return `role=${role}[name=${name}]`;
}
return `role=${role}`;
}

// 4. Check for visible text
const text = element.textContent.trim();
if (text) {
return `text="${text}"`;
}

// 5. Fallback to XPath
return generateXPath(element);
}

// Helper function to compute the role (explicit or implicit)
function getRole(element) {
// Check for explicit role
if (element.hasAttribute('role')) {
return element.getAttribute('role');
}

// Implicit role mapping
const implicitRoles = {
button: ['button', "input[type='button']", "input[type='submit']", "input[type='reset']"],
link: ['a[href]'],
checkbox: ["input[type='checkbox']"],
heading: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
dialog: ['dialog'],
img: ['img[alt]'],
textbox: ["input[type='text']", 'textarea'],
radio: ["input[type='radio']"],
// Add more implicit roles if needed
};

for (const [role, selectors] of Object.entries(implicitRoles)) {
for (const selector of selectors) {
if (element.matches(selector)) {
return role;
}
}
}

return null;
}

// Helper function to compute the accessible name of an element
function getAccessibleName(element) {
// Prefer aria-label or aria-labelledby
if (element.hasAttribute('aria-label')) {
return element.getAttribute('aria-label');
}
if (element.hasAttribute('aria-labelledby')) {
const labelId = element.getAttribute('aria-labelledby');
const labelElement = element.ownerDocument.getElementById(labelId);
return labelElement ? labelElement.textContent.trim() : '';
}
// Use text content as a fallback
return element.textContent.trim();
}

// Helper function to generate XPath as a fallback
function generateXPath(element) {
if (element.id) {
return `//*[@id="${element.id}"]`;
}
const siblings = Array.from(element.parentNode.children).filter(
(el) => el.nodeName === element.nodeName
);
const index = siblings.indexOf(element) + 1;
const tagName = element.nodeName.toLowerCase();
if (element.parentNode === document) {
return `/${tagName}[${index}]`;
}
return `${generateXPath(element.parentNode)}/${tagName}[${index}]`;
}


// Highlight and Selector Display
let lastHighlightedElement = null;
const selectorOverlay = document.createElement('div');
selectorOverlay.style.position = 'absolute';
Expand All @@ -11,44 +107,6 @@
selectorOverlay.style.zIndex = '9999';
document.body.appendChild(selectorOverlay);

function getXPath(element) {
if (!element || element.nodeType !== Node.ELEMENT_NODE) return '';

// 1. Check for data-testid
if (element.hasAttribute('data-testid')) {
return `//*[@data-testid="${element.getAttribute('data-testid')}"]`;
}

// 2. Check for id
if (element.id) {
return `//*[@id="${element.id}"]`;
}

// 3. Check for unique text content
const tagName = element.nodeName.toLowerCase();
if (['button', 'a', 'span', 'div'].includes(tagName) && element.textContent.trim()) {
const text = element.textContent.trim();
return `//${tagName}[text()="${text}"]`;
}

// 4. Check for unique href attribute
if (element.hasAttribute('href')) {
return `//${tagName}[@href="${element.getAttribute('href')}"]`;
}

// 5. Fallback to sibling index
let index = 1;
let sibling = element.previousElementSibling;
while (sibling) {
if (sibling.nodeName === element.nodeName) {
index++;
}
sibling = sibling.previousElementSibling;
}

return `${getXPath(element.parentNode)}/${tagName}[${index}]`;
}

function highlightElement(event) {
if (lastHighlightedElement) {
lastHighlightedElement.style.outline = '';
Expand All @@ -57,9 +115,9 @@
element.style.outline = '2px solid #FF671D';
lastHighlightedElement = element;

const xpath = getXPath(element);
const selector = findBestSelector(element);
const rect = element.getBoundingClientRect();
selectorOverlay.textContent = xpath;
selectorOverlay.textContent = selector;
selectorOverlay.style.top = `${rect.top + window.scrollY}px`;
selectorOverlay.style.left = `${rect.left + window.scrollX}px`;
}
Expand Down

0 comments on commit 6e77aaa

Please sign in to comment.