Refactor ExtensionLocator to support parent-child relationships and enhance element interaction methods. Introduce a selector chain for improved querying and update click, fill, and wait_for methods to utilize JavaScript for better performance and reliability. Simplify error handling and improve code readability.
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import contextlib
|
||||
import importlib
|
||||
import pkgutil
|
||||
from pathlib import Path
|
||||
@@ -28,11 +29,8 @@ def _discover_action_modules() -> None:
|
||||
if module_name.endswith(("base", "registry")):
|
||||
continue
|
||||
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
_ = importlib.import_module(module_name)
|
||||
except Exception:
|
||||
# Silently skip modules that fail to import
|
||||
pass
|
||||
|
||||
|
||||
def default_registry(persona_store: PersonaStore, login_url: str) -> ActionRegistry:
|
||||
|
||||
@@ -431,65 +431,174 @@ class ExtensionLocator:
|
||||
Provides methods for interacting with located elements.
|
||||
"""
|
||||
|
||||
def __init__(self, page: ExtensionPage, selector: str) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
page: ExtensionPage,
|
||||
selector: str,
|
||||
index: int | None = None,
|
||||
parent: "ExtensionLocator | None" = None,
|
||||
) -> None:
|
||||
self._page = page
|
||||
self._selector = selector
|
||||
self._index = index
|
||||
self._parent = parent
|
||||
|
||||
def _selector_chain(self) -> list[dict[str, int | str | None]]:
|
||||
"""Build a selector chain from root to this locator."""
|
||||
|
||||
chain: list[dict[str, int | str | None]] = []
|
||||
node: ExtensionLocator | None = self
|
||||
while node:
|
||||
chain.append({"selector": node._selector, "index": node._index})
|
||||
node = node._parent
|
||||
chain.reverse()
|
||||
return chain
|
||||
|
||||
def _build_chain_eval(self, body: str, empty_expr: str) -> str:
|
||||
"""Generate JS that resolves the locator chain then runs body."""
|
||||
|
||||
chain = json.dumps(self._selector_chain())
|
||||
return f"""
|
||||
const chain = {chain};
|
||||
let contexts = [document];
|
||||
for (const step of chain) {{
|
||||
let next = [];
|
||||
for (const ctx of contexts) {{
|
||||
next.push(...ctx.querySelectorAll(step.selector));
|
||||
}}
|
||||
if (step.index !== null && step.index !== undefined) {{
|
||||
const el = next[step.index];
|
||||
next = el ? [el] : [];
|
||||
}}
|
||||
contexts = next;
|
||||
}}
|
||||
if (!contexts.length) {{ {empty_expr} }}
|
||||
const target = contexts[0];
|
||||
{body}
|
||||
"""
|
||||
|
||||
def locator(self, selector: str) -> "PageLocator":
|
||||
"""Find a child element within this locator.
|
||||
|
||||
Args:
|
||||
selector: CSS selector relative to parent
|
||||
|
||||
Returns:
|
||||
New ExtensionLocator for the child
|
||||
Keeps the selector chain (including nth index) so the child
|
||||
query is scoped to the same parent element.
|
||||
"""
|
||||
# Combine selectors with descendant combinator
|
||||
combined = f"{self._selector} {selector}"
|
||||
return cast(PageLocator, ExtensionLocator(self._page, combined))
|
||||
|
||||
return cast(PageLocator, ExtensionLocator(self._page, selector, None, self))
|
||||
|
||||
async def click(self) -> None:
|
||||
"""Click the located element."""
|
||||
await self._page.click(self._selector)
|
||||
|
||||
js_code = self._build_chain_eval(
|
||||
"target.click(); return true;",
|
||||
"throw new Error('Element not found');",
|
||||
)
|
||||
await self._page.eval_js(js_code)
|
||||
|
||||
async def fill(self, value: str) -> None:
|
||||
"""Fill the located element with a value."""
|
||||
await self._page.fill(self._selector, value)
|
||||
|
||||
escaped_value = value.replace("'", "\\'").replace('"', '\\"')
|
||||
js_code = self._build_chain_eval(
|
||||
f"""
|
||||
const el = target;
|
||||
const tagName = el.tagName.toLowerCase();
|
||||
let nativeSetter;
|
||||
|
||||
if (tagName === 'textarea') {{
|
||||
nativeSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLTextAreaElement.prototype,
|
||||
'value'
|
||||
).set;
|
||||
}} else if (tagName === 'input') {{
|
||||
nativeSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
'value'
|
||||
).set;
|
||||
}} else {{
|
||||
throw new Error('Element must be input or textarea');
|
||||
}}
|
||||
|
||||
nativeSetter.call(el, '{escaped_value}');
|
||||
el.dispatchEvent(new Event('input', {{ bubbles: true }}));
|
||||
el.dispatchEvent(new Event('change', {{ bubbles: true }}));
|
||||
return true;
|
||||
""",
|
||||
"throw new Error('Element not found');",
|
||||
)
|
||||
await self._page.eval_js(js_code)
|
||||
|
||||
async def type(self, text: str) -> None:
|
||||
"""Type text into the located element."""
|
||||
await self._page.type(self._selector, text)
|
||||
|
||||
escaped_text = text.replace("'", "\\'").replace('"', '\\"')
|
||||
js_code = self._build_chain_eval(
|
||||
f"""
|
||||
const el = target;
|
||||
const tagName = el.tagName.toLowerCase();
|
||||
let nativeSetter;
|
||||
|
||||
if (tagName === 'textarea') {{
|
||||
nativeSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLTextAreaElement.prototype,
|
||||
'value'
|
||||
).set;
|
||||
}} else if (tagName === 'input') {{
|
||||
nativeSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
'value'
|
||||
).set;
|
||||
}} else {{
|
||||
throw new Error('Element must be input or textarea');
|
||||
}}
|
||||
|
||||
nativeSetter.call(el, '{escaped_text}');
|
||||
el.dispatchEvent(new Event('input', {{ bubbles: true }}));
|
||||
el.dispatchEvent(new Event('change', {{ bubbles: true }}));
|
||||
return true;
|
||||
""",
|
||||
"throw new Error('Element not found');",
|
||||
)
|
||||
await self._page.eval_js(js_code)
|
||||
|
||||
async def wait_for(self, state: str = "visible", timeout: int = 5000) -> None:
|
||||
"""Wait for the element to reach a specific state.
|
||||
"""Wait for the element to reach a specific state."""
|
||||
|
||||
Args:
|
||||
state: State to wait for (visible, hidden, attached, detached)
|
||||
timeout: Timeout in milliseconds
|
||||
|
||||
Raises:
|
||||
Exception: If timeout or element not found
|
||||
"""
|
||||
chain = json.dumps(self._selector_chain())
|
||||
js_code = f"""
|
||||
const selector = '{self._selector}';
|
||||
const chain = {chain};
|
||||
const state = '{state}';
|
||||
const timeout = {timeout};
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
function checkState() {{
|
||||
const el = document.querySelector(selector);
|
||||
|
||||
if (state === 'visible') {{
|
||||
return el && el.offsetParent !== null;
|
||||
}} else if (state === 'hidden') {{
|
||||
return !el || el.offsetParent === null;
|
||||
}} else if (state === 'attached') {{
|
||||
return !!el;
|
||||
}} else if (state === 'detached') {{
|
||||
return !el;
|
||||
function resolveChain() {{
|
||||
let contexts = [document];
|
||||
for (const step of chain) {{
|
||||
let next = [];
|
||||
for (const ctx of contexts) {{
|
||||
next.push(...ctx.querySelectorAll(step.selector));
|
||||
}}
|
||||
if (step.index !== null && step.index !== undefined) {{
|
||||
const el = next[step.index];
|
||||
next = el ? [el] : [];
|
||||
}}
|
||||
contexts = next;
|
||||
}}
|
||||
return false;
|
||||
return contexts;
|
||||
}}
|
||||
|
||||
function checkState() {{
|
||||
const matches = resolveChain();
|
||||
if (state === 'visible') {{
|
||||
return matches.some(el => el && el.offsetParent !== null);
|
||||
}} else if (state === 'hidden') {{
|
||||
return matches.length === 0 || matches.every(el => el && el.offsetParent === null);
|
||||
}} else if (state === 'attached') {{
|
||||
return matches.length > 0;
|
||||
}} else if (state === 'detached') {{
|
||||
return matches.length === 0;
|
||||
}}
|
||||
return matches.length > 0;
|
||||
}}
|
||||
|
||||
return new Promise((resolve, reject) => {{
|
||||
@@ -499,7 +608,7 @@ class ExtensionLocator:
|
||||
resolve(true);
|
||||
}} else if (Date.now() - startTime > timeout) {{
|
||||
clearInterval(interval);
|
||||
reject(new Error(`Timeout waiting for element to be ${{state}}: ${{selector}}`));
|
||||
reject(new Error('Timeout waiting for element state: ' + state));
|
||||
}}
|
||||
}}, 100);
|
||||
}});
|
||||
@@ -507,51 +616,46 @@ class ExtensionLocator:
|
||||
await self._page.eval_js(js_code, await_promise=True)
|
||||
|
||||
async def count(self) -> int:
|
||||
"""Get the count of matching elements.
|
||||
"""Get the count of matching elements."""
|
||||
|
||||
Returns:
|
||||
Number of elements matching the selector
|
||||
"""
|
||||
chain = json.dumps(self._selector_chain())
|
||||
js_code = f"""
|
||||
const selector = '{self._selector}';
|
||||
return document.querySelectorAll(selector).length;
|
||||
const chain = {chain};
|
||||
let contexts = [document];
|
||||
for (const step of chain) {{
|
||||
let next = [];
|
||||
for (const ctx of contexts) {{
|
||||
next.push(...ctx.querySelectorAll(step.selector));
|
||||
}}
|
||||
if (step.index !== null && step.index !== undefined) {{
|
||||
const el = next[step.index];
|
||||
next = el ? [el] : [];
|
||||
}}
|
||||
contexts = next;
|
||||
}}
|
||||
return contexts.length;
|
||||
"""
|
||||
|
||||
result = await self._page.eval_js(js_code)
|
||||
return int(result) if isinstance(result, (int, float)) else 0
|
||||
|
||||
async def text_content(self) -> str | None:
|
||||
"""Get the text content of the located element.
|
||||
"""Get the text content of the located element."""
|
||||
|
||||
Returns:
|
||||
Text content of the element, or None if element not found
|
||||
"""
|
||||
js_code = f"""
|
||||
const el = document.querySelector('{self._selector}');
|
||||
return el ? el.textContent : null;
|
||||
"""
|
||||
js_code = self._build_chain_eval("return target ? target.textContent : null;", "return null;")
|
||||
result = await self._page.eval_js(js_code)
|
||||
return str(result) if result is not None else None
|
||||
|
||||
def nth(self, index: int) -> "PageLocator":
|
||||
"""Get the nth matching element.
|
||||
"""Get the nth matching element."""
|
||||
|
||||
Args:
|
||||
index: Zero-based index of the element
|
||||
|
||||
Returns:
|
||||
ExtensionLocator for the nth element (for now, returns self)
|
||||
"""
|
||||
# For now, querySelector returns first match, so we return self
|
||||
# In the future, this could be enhanced to support nth-child
|
||||
return cast(PageLocator, self)
|
||||
return cast(PageLocator, ExtensionLocator(self._page, self._selector, index, self._parent))
|
||||
|
||||
@property
|
||||
def first(self) -> "PageLocator":
|
||||
"""Get the first matching element.
|
||||
"""Get the first matching element."""
|
||||
|
||||
For now, returns self as querySelector returns first match.
|
||||
"""
|
||||
return cast(PageLocator, self)
|
||||
return self.nth(0)
|
||||
|
||||
|
||||
class ExtensionClient:
|
||||
|
||||
Reference in New Issue
Block a user