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:
2025-11-24 05:32:51 +00:00
parent 2fad7ccbee
commit fbc2dd3494
2 changed files with 169 additions and 67 deletions

View File

@@ -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:

View File

@@ -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: