mirror of
https://github.com/guoriyue/AutoMouser.git
synced 2026-06-03 21:02:31 +08:00
633 lines
22 KiB
JavaScript
633 lines
22 KiB
JavaScript
// Element utilities - shared functions for element manipulation and validation
|
|
// Can be used by both content scripts and background scripts
|
|
|
|
// Class patterns that commonly contain question/label text (for hybrid extraction)
|
|
const LABEL_CLASS_PATTERNS = [
|
|
'label', 'question', 'field', 'form-group', 'prompt',
|
|
'control-label', 'field-label', 'form-label', 'input-label'
|
|
];
|
|
|
|
// Standalone utility functions (can be used without instantiating a class)
|
|
|
|
/**
|
|
* Find element by XPath
|
|
*/
|
|
export function findElement(xpath) {
|
|
try {
|
|
const result = document.evaluate(
|
|
xpath,
|
|
document,
|
|
null,
|
|
XPathResult.FIRST_ORDERED_NODE_TYPE,
|
|
null
|
|
);
|
|
return result.singleNodeValue;
|
|
} catch (e) {
|
|
console.error('XPath evaluation error:', e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if element is visible to user
|
|
*/
|
|
export function isElementVisible(element) {
|
|
if (!element) return false;
|
|
|
|
const style = window.getComputedStyle(element);
|
|
const rect = element.getBoundingClientRect();
|
|
|
|
return style.display !== 'none' &&
|
|
style.visibility !== 'hidden' &&
|
|
style.opacity !== '0' &&
|
|
rect.width > 0 &&
|
|
rect.height > 0 &&
|
|
(rect.top + rect.height) > 0 &&
|
|
(rect.left + rect.width) > 0;
|
|
}
|
|
|
|
/**
|
|
* Validate XPath syntax and check if it finds elements
|
|
*/
|
|
export function validateXPath(xpath) {
|
|
try {
|
|
const result = document.evaluate(xpath, document, null, XPathResult.ANY_TYPE, null);
|
|
let count = 0;
|
|
let node = result.iterateNext();
|
|
while (node) {
|
|
count++;
|
|
node = result.iterateNext();
|
|
}
|
|
return {
|
|
valid: true,
|
|
count: count,
|
|
isUnique: count === 1
|
|
};
|
|
} catch (e) {
|
|
return {
|
|
valid: false,
|
|
error: e.message
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get element value handling different input types
|
|
*/
|
|
export function getElementValue(element) {
|
|
if (!element) return '';
|
|
|
|
const tagName = element.tagName.toLowerCase();
|
|
|
|
if (tagName === 'input' || tagName === 'textarea') {
|
|
return element.value;
|
|
} else if (tagName === 'select') {
|
|
const selectedOption = element.options[element.selectedIndex];
|
|
return {
|
|
value: element.value,
|
|
text: selectedOption ? selectedOption.text : '',
|
|
selectedIndex: element.selectedIndex
|
|
};
|
|
} else if (element.isContentEditable) {
|
|
return element.textContent;
|
|
} else {
|
|
return element.textContent.trim();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if element is interactive (clickable, editable, etc.)
|
|
*/
|
|
export function isElementInteractive(element) {
|
|
if (!element) return false;
|
|
|
|
const tagName = element.tagName.toLowerCase();
|
|
const interactiveTags = ['button', 'a', 'input', 'select', 'textarea'];
|
|
|
|
return interactiveTags.includes(tagName) ||
|
|
element.onclick !== null ||
|
|
element.getAttribute('onclick') !== null ||
|
|
element.hasAttribute('role') ||
|
|
element.tabIndex >= 0 ||
|
|
element.isContentEditable;
|
|
}
|
|
|
|
/**
|
|
* Get element's position relative to viewport
|
|
*/
|
|
export function getElementPosition(element) {
|
|
if (!element) return null;
|
|
|
|
const rect = element.getBoundingClientRect();
|
|
return {
|
|
top: rect.top,
|
|
left: rect.left,
|
|
bottom: rect.bottom,
|
|
right: rect.right,
|
|
width: rect.width,
|
|
height: rect.height,
|
|
centerX: rect.left + rect.width / 2,
|
|
centerY: rect.top + rect.height / 2
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Extract label text for an element using multiple strategies
|
|
* Priority: explicit label > aria-labelledby > aria-label > wrapping label > placeholder > legend
|
|
* > aria-describedby > hybrid question text (for radio/checkbox) > nearby text fallback
|
|
*/
|
|
export function getLabelForElement(element) {
|
|
if (!element) return '';
|
|
|
|
// Strategy 1: Explicit <label for="id">
|
|
if (element.id) {
|
|
const label = document.querySelector(`label[for="${CSS.escape(element.id)}"]`);
|
|
if (label) return cleanLabelText(label, element);
|
|
}
|
|
|
|
// Strategy 2: aria-labelledby (references another element)
|
|
const labelledBy = element.getAttribute('aria-labelledby');
|
|
if (labelledBy) {
|
|
const labelEl = document.getElementById(labelledBy);
|
|
if (labelEl) return cleanLabelText(labelEl, element);
|
|
}
|
|
|
|
// Strategy 3: aria-label attribute
|
|
const ariaLabel = element.getAttribute('aria-label');
|
|
if (ariaLabel) return ariaLabel.trim();
|
|
|
|
// Strategy 4: Implicit label (element wrapped in <label>)
|
|
const parentLabel = element.closest('label');
|
|
if (parentLabel) return cleanLabelText(parentLabel, element);
|
|
|
|
// Strategy 5: Placeholder (fallback for inputs)
|
|
if (element.placeholder) return element.placeholder.trim();
|
|
|
|
// Strategy 6: Legend in fieldset
|
|
const fieldset = element.closest('fieldset');
|
|
if (fieldset) {
|
|
const legend = fieldset.querySelector('legend');
|
|
if (legend) return cleanLabelText(legend, element);
|
|
}
|
|
|
|
// Strategy 7: aria-describedby (common for questions)
|
|
const describedBy = element.getAttribute('aria-describedby');
|
|
if (describedBy) {
|
|
const descEl = document.getElementById(describedBy);
|
|
if (descEl) return cleanLabelText(descEl, element);
|
|
}
|
|
|
|
// Strategy 8: For radio/checkbox - find question using hybrid approach
|
|
if (element.type === 'radio' || element.type === 'checkbox') {
|
|
const questionText = findQuestionText(element);
|
|
if (questionText) return questionText;
|
|
}
|
|
|
|
// Strategy 9: General fallback - DOM traversal for nearby text
|
|
const nearbyText = findNearbyLabelText(element);
|
|
if (nearbyText) return nearbyText;
|
|
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* Clean label text - remove element's own value and extra whitespace
|
|
*/
|
|
function cleanLabelText(labelElement, inputElement) {
|
|
if (!labelElement) return '';
|
|
|
|
// Clone to avoid modifying the DOM
|
|
const clone = labelElement.cloneNode(true);
|
|
|
|
// Remove the input element itself from the clone (for wrapping labels)
|
|
const inputInClone = clone.querySelector('input, select, textarea');
|
|
if (inputInClone) {
|
|
inputInClone.remove();
|
|
}
|
|
|
|
// Get text and normalize whitespace
|
|
return clone.textContent?.replace(/\s+/g, ' ').trim() || '';
|
|
}
|
|
|
|
/**
|
|
* Hybrid question text extraction for radio/checkbox
|
|
* Step 1: Class-based heuristics (preferred - more precise)
|
|
* Step 2: DOM traversal fallback (high recall)
|
|
*/
|
|
function findQuestionText(element) {
|
|
// === STEP 1: Class-based heuristics (preferred) ===
|
|
let ancestor = element.parentElement;
|
|
let depth = 0;
|
|
|
|
while (ancestor && depth < 6) {
|
|
// Check if ancestor has label-like class
|
|
const ancestorClasses = (ancestor.className || '').toString().toLowerCase();
|
|
const hasLabelClass = LABEL_CLASS_PATTERNS.some(p => ancestorClasses.includes(p));
|
|
|
|
if (hasLabelClass) {
|
|
// Look for children/siblings with question text
|
|
const questionEl = findQuestionElementInContainer(ancestor, element);
|
|
if (questionEl) {
|
|
const text = cleanLabelText(questionEl, element);
|
|
if (text && text.length > 10) return text;
|
|
}
|
|
}
|
|
|
|
// Also check children of ancestor for label-like elements
|
|
for (const child of ancestor.children) {
|
|
if (child.contains(element)) continue; // Skip branch containing our element
|
|
|
|
const childClasses = (child.className || '').toString().toLowerCase();
|
|
if (LABEL_CLASS_PATTERNS.some(p => childClasses.includes(p))) {
|
|
const text = child.textContent?.trim();
|
|
if (text && text.length > 10 && text.length < 500) {
|
|
return text;
|
|
}
|
|
}
|
|
}
|
|
|
|
ancestor = ancestor.parentElement;
|
|
depth++;
|
|
}
|
|
|
|
// === STEP 2: DOM traversal fallback (high recall) ===
|
|
return findNearbyLabelText(element);
|
|
}
|
|
|
|
/**
|
|
* Find question element within a container
|
|
*/
|
|
function findQuestionElementInContainer(container, inputElement) {
|
|
// Look for semantic elements that typically contain questions
|
|
const selectors = [
|
|
'label:not(:has(input))', // Labels that don't wrap inputs
|
|
'.question', '.label', '.prompt',
|
|
'[class*="label"]:not(input)',
|
|
'h3', 'h4', 'h5', 'p', 'span', 'div'
|
|
];
|
|
|
|
for (const selector of selectors) {
|
|
try {
|
|
const el = container.querySelector(selector);
|
|
if (el && !el.contains(inputElement)) {
|
|
const text = el.textContent?.trim();
|
|
if (text && text.length > 10 && text.length < 500) {
|
|
return el;
|
|
}
|
|
}
|
|
} catch (e) { /* Skip invalid selectors */ }
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* DOM traversal fallback - look for text in ancestors/siblings
|
|
* High recall: will find question text even without semantic markup
|
|
*/
|
|
function findNearbyLabelText(element) {
|
|
let parent = element.parentElement;
|
|
let depth = 0;
|
|
|
|
while (parent && depth < 5) {
|
|
// Check preceding siblings for text blocks
|
|
let sibling = parent.previousElementSibling;
|
|
let siblingDepth = 0;
|
|
|
|
while (sibling && siblingDepth < 3) {
|
|
const text = sibling.textContent?.trim();
|
|
// Must be substantial text (> 10 chars), not just "Yes"/"No"
|
|
if (text && text.length > 10 && text.length < 500) {
|
|
// Verify it's not another form field
|
|
if (!sibling.querySelector('input, select, textarea, button')) {
|
|
return text;
|
|
}
|
|
}
|
|
sibling = sibling.previousElementSibling;
|
|
siblingDepth++;
|
|
}
|
|
|
|
// Check parent's first child if it looks like a label
|
|
const firstChild = parent.firstElementChild;
|
|
if (firstChild && !firstChild.contains(element)) {
|
|
const text = firstChild.textContent?.trim();
|
|
if (text && text.length > 10 && text.length < 500) {
|
|
if (!firstChild.querySelector('input, select, textarea, button')) {
|
|
return text;
|
|
}
|
|
}
|
|
}
|
|
|
|
parent = parent.parentElement;
|
|
depth++;
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
// Element assertion helpers for QA testing - checking element existence, values, etc.
|
|
export class ElementAssertions {
|
|
constructor(sendMessage, getXpaths) {
|
|
this.sendMessage = sendMessage;
|
|
this.getXpaths = getXpaths;
|
|
}
|
|
|
|
init() {
|
|
// Listen for assertion requests from background script
|
|
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
|
|
if (request.action === 'assertElement') {
|
|
const result = this.assertElement(request.xpath, request.assertion);
|
|
sendResponse(result);
|
|
} else if (request.action === 'getElementValue') {
|
|
const value = this.getElementValue(request.xpath);
|
|
sendResponse(value);
|
|
} else if (request.action === 'getElementProperties') {
|
|
const props = this.getElementProperties(request.xpath);
|
|
sendResponse(props);
|
|
} else if (request.action === 'waitForElement') {
|
|
this.waitForElement(request.xpath, request.timeout)
|
|
.then(result => sendResponse(result))
|
|
.catch(error => sendResponse({ success: false, error: error.message }));
|
|
return true; // Keep channel open for async response
|
|
}
|
|
});
|
|
}
|
|
|
|
// Find element by XPath
|
|
findElement(xpath) {
|
|
try {
|
|
const result = document.evaluate(
|
|
xpath,
|
|
document,
|
|
null,
|
|
XPathResult.FIRST_ORDERED_NODE_TYPE,
|
|
null
|
|
);
|
|
return result.singleNodeValue;
|
|
} catch (e) {
|
|
console.error('XPath evaluation error:', e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Assert element conditions
|
|
assertElement(xpath, assertion) {
|
|
const element = this.findElement(xpath);
|
|
|
|
const result = {
|
|
xpath: xpath,
|
|
assertion: assertion,
|
|
success: false,
|
|
element: null,
|
|
message: ''
|
|
};
|
|
|
|
if (!element) {
|
|
result.message = `Element not found: ${xpath}`;
|
|
return result;
|
|
}
|
|
|
|
result.element = {
|
|
exists: true,
|
|
tagName: element.tagName,
|
|
id: element.id,
|
|
className: element.className
|
|
};
|
|
|
|
switch (assertion.type) {
|
|
case 'exists':
|
|
result.success = true;
|
|
result.message = 'Element exists';
|
|
break;
|
|
|
|
case 'visible':
|
|
result.success = this.isElementVisible(element);
|
|
result.message = result.success ? 'Element is visible' : 'Element is not visible';
|
|
break;
|
|
|
|
case 'enabled':
|
|
result.success = !element.disabled;
|
|
result.message = result.success ? 'Element is enabled' : 'Element is disabled';
|
|
break;
|
|
|
|
case 'text_equals':
|
|
const actualText = element.textContent.trim();
|
|
result.success = actualText === assertion.value;
|
|
result.actualValue = actualText;
|
|
result.message = result.success ?
|
|
'Text matches expected value' :
|
|
`Text mismatch. Expected: "${assertion.value}", Actual: "${actualText}"`;
|
|
break;
|
|
|
|
case 'text_contains':
|
|
const elementText = element.textContent.trim();
|
|
result.success = elementText.includes(assertion.value);
|
|
result.actualValue = elementText;
|
|
result.message = result.success ?
|
|
'Text contains expected value' :
|
|
`Text does not contain "${assertion.value}"`;
|
|
break;
|
|
|
|
case 'value_equals':
|
|
const actualValue = element.value || '';
|
|
result.success = actualValue === assertion.value;
|
|
result.actualValue = actualValue;
|
|
result.message = result.success ?
|
|
'Value matches expected' :
|
|
`Value mismatch. Expected: "${assertion.value}", Actual: "${actualValue}"`;
|
|
break;
|
|
|
|
case 'attribute_equals':
|
|
const attrValue = element.getAttribute(assertion.attribute) || '';
|
|
result.success = attrValue === assertion.value;
|
|
result.actualValue = attrValue;
|
|
result.message = result.success ?
|
|
`Attribute ${assertion.attribute} matches expected value` :
|
|
`Attribute mismatch. Expected: "${assertion.value}", Actual: "${attrValue}"`;
|
|
break;
|
|
|
|
case 'css_property_equals':
|
|
const computedStyle = window.getComputedStyle(element);
|
|
const cssValue = computedStyle.getPropertyValue(assertion.property);
|
|
result.success = cssValue === assertion.value;
|
|
result.actualValue = cssValue;
|
|
result.message = result.success ?
|
|
`CSS property ${assertion.property} matches expected value` :
|
|
`CSS mismatch. Expected: "${assertion.value}", Actual: "${cssValue}"`;
|
|
break;
|
|
|
|
case 'selected':
|
|
result.success = element.selected || element.checked;
|
|
result.message = result.success ? 'Element is selected' : 'Element is not selected';
|
|
break;
|
|
|
|
case 'has_class':
|
|
result.success = element.classList.contains(assertion.className);
|
|
result.message = result.success ?
|
|
`Element has class "${assertion.className}"` :
|
|
`Element does not have class "${assertion.className}"`;
|
|
break;
|
|
|
|
default:
|
|
result.message = `Unknown assertion type: ${assertion.type}`;
|
|
}
|
|
|
|
// Log assertion for tracking
|
|
this.sendMessage({
|
|
message: "onAssertion",
|
|
result: result
|
|
});
|
|
|
|
return result;
|
|
}
|
|
|
|
// Check if element is visible
|
|
isElementVisible(element) {
|
|
if (!element) return false;
|
|
|
|
const style = window.getComputedStyle(element);
|
|
const rect = element.getBoundingClientRect();
|
|
|
|
return style.display !== 'none' &&
|
|
style.visibility !== 'hidden' &&
|
|
style.opacity !== '0' &&
|
|
rect.width > 0 &&
|
|
rect.height > 0 &&
|
|
(rect.top + rect.height) > 0 &&
|
|
(rect.left + rect.width) > 0;
|
|
}
|
|
|
|
// Get element value (handles different element types)
|
|
getElementValue(xpath) {
|
|
const element = this.findElement(xpath);
|
|
if (!element) {
|
|
return { success: false, error: 'Element not found', xpath: xpath };
|
|
}
|
|
|
|
let value = '';
|
|
const tagName = element.tagName.toLowerCase();
|
|
|
|
if (tagName === 'input' || tagName === 'textarea') {
|
|
value = element.value;
|
|
} else if (tagName === 'select') {
|
|
const selectedOption = element.options[element.selectedIndex];
|
|
value = {
|
|
value: element.value,
|
|
text: selectedOption ? selectedOption.text : '',
|
|
selectedIndex: element.selectedIndex
|
|
};
|
|
} else if (element.isContentEditable) {
|
|
value = element.textContent;
|
|
} else {
|
|
value = element.textContent.trim();
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
xpath: xpath,
|
|
value: value,
|
|
tagName: tagName,
|
|
type: element.type || null
|
|
};
|
|
}
|
|
|
|
// Get comprehensive element properties
|
|
getElementProperties(xpath) {
|
|
const element = this.findElement(xpath);
|
|
if (!element) {
|
|
return { success: false, error: 'Element not found', xpath: xpath };
|
|
}
|
|
|
|
const rect = element.getBoundingClientRect();
|
|
const computedStyle = window.getComputedStyle(element);
|
|
|
|
return {
|
|
success: true,
|
|
xpath: xpath,
|
|
properties: {
|
|
// Basic properties
|
|
tagName: element.tagName,
|
|
id: element.id,
|
|
className: element.className,
|
|
classList: Array.from(element.classList),
|
|
|
|
// Content
|
|
textContent: element.textContent.trim(),
|
|
innerHTML: element.innerHTML,
|
|
value: element.value || null,
|
|
|
|
// Attributes
|
|
attributes: Array.from(element.attributes).reduce((acc, attr) => {
|
|
acc[attr.name] = attr.value;
|
|
return acc;
|
|
}, {}),
|
|
|
|
// State
|
|
disabled: element.disabled || false,
|
|
readonly: element.readOnly || false,
|
|
required: element.required || false,
|
|
checked: element.checked || false,
|
|
selected: element.selected || false,
|
|
|
|
// Position and dimensions
|
|
position: {
|
|
top: rect.top,
|
|
left: rect.left,
|
|
bottom: rect.bottom,
|
|
right: rect.right,
|
|
width: rect.width,
|
|
height: rect.height
|
|
},
|
|
|
|
// Visibility
|
|
visible: this.isElementVisible(element),
|
|
display: computedStyle.display,
|
|
visibility: computedStyle.visibility,
|
|
opacity: computedStyle.opacity,
|
|
|
|
// Form validation (if applicable)
|
|
validity: element.validity || null,
|
|
validationMessage: element.validationMessage || null,
|
|
|
|
// Parent and children info
|
|
parentTag: element.parentElement ? element.parentElement.tagName : null,
|
|
childCount: element.children.length,
|
|
|
|
// Custom data attributes
|
|
dataset: element.dataset ? { ...element.dataset } : {}
|
|
}
|
|
};
|
|
}
|
|
|
|
// Wait for element to appear
|
|
async waitForElement(xpath, timeout = 10000) {
|
|
const startTime = Date.now();
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const checkInterval = 100; // Check every 100ms
|
|
|
|
const check = () => {
|
|
const element = this.findElement(xpath);
|
|
|
|
if (element && this.isElementVisible(element)) {
|
|
resolve({
|
|
success: true,
|
|
xpath: xpath,
|
|
waitTime: Date.now() - startTime,
|
|
element: {
|
|
tagName: element.tagName,
|
|
id: element.id,
|
|
className: element.className
|
|
}
|
|
});
|
|
} else if (Date.now() - startTime > timeout) {
|
|
reject(new Error(`Timeout waiting for element: ${xpath}`));
|
|
} else {
|
|
setTimeout(check, checkInterval);
|
|
}
|
|
};
|
|
|
|
check();
|
|
});
|
|
}
|
|
} |