30 KiB
Based on the troubleshooting.md (which shows a complex React/Material UI form with data-cy attributes) and your existing codebase, here is the solution divided into two parts:
- Browser Elements: A robust set of Python helpers (
mui.py) specifically designed to conquer the Material UI structure seen in your HTML. - Extension Integration: An "airtight" implementation of
worker.jsand updates toextension_client.pyto ensure reliable execution of these helpers via the Chrome Extension architecture (Service Worker <-> Content Script <-> DOM).
Part 1: Robust Browser Elements (src/guide/app/browser/elements/mui.py)
I recommend creating a dedicated mui.py file. The generic dropdown.py is often too broad for the specific nesting of MUI v5 components seen in your troubleshooting.md.
This file handles the "React Hack" (triggering events so state updates) and targets the stable data-cy wrappers.
# src/guide/app/browser/elements/mui.py
import logging
from typing import Any
from guide.app.browser.types import PageLike
from guide.app.browser.elements.dropdown import click_with_mouse_events
_logger = logging.getLogger(__name__)
async def select_mui_dropdown(
page: PageLike,
wrapper_selector: str,
value_text: str
) -> None:
"""
Selects an option from a Material UI Select component (non-searchable dropdown).
Target Structure (from troubleshooting.md):
<div data-cy="contract-form-field-type"> ... <div role="combobox">
Args:
page: The page or extension client.
wrapper_selector: The data-cy attribute or selector for the wrapper (e.g., '[data-cy="contract-form-field-type"]').
value_text: The visible text of the option to select.
"""
# 1. Target the clickable combobox div within the wrapper
# We avoid the input[type="hidden"] and target the visible div
trigger_selector = f"{wrapper_selector} [role='combobox']"
# 2. Click to open the listbox
await click_with_mouse_events(page, trigger_selector)
# 3. Wait for the listbox (MUI attaches this to document.body, usually via a Portal)
# We look for a listbox that is visible.
await page.wait_for_selector('ul[role="listbox"]', timeout=2000)
# 4. Find and click the specific option
# MUI options usually have role="option". We use XPath to match text exact or contains.
option_selector = f'//ul[@role="listbox"]//li[@role="option"][contains(text(), "{value_text}")]'
# Fallback: strict text match if fuzzy fails
try:
await page.click(option_selector)
except Exception:
# Try exact match
await page.click(f'//ul[@role="listbox"]//li[@role="option" and text()="{value_text}"]')
# 5. Wait for listbox to detach (confirm selection)
try:
await page.wait_for_selector('ul[role="listbox"]', state="detached", timeout=1000)
except Exception:
# If it didn't close, we might need to press Escape, but usually click works.
pass
async def select_mui_autocomplete(
page: PageLike,
wrapper_selector: str,
value_text: str,
clear_existing: bool = True
) -> None:
"""
Selects an option from a Material UI Autocomplete (searchable + combobox).
Target Structure (from troubleshooting.md):
<div data-cy="contract-form-field-supplier"> ... <input role="combobox">
"""
input_selector = f"{wrapper_selector} input[role='combobox']"
# 1. Clear existing value if needed (via the small 'x' button if present)
if clear_existing:
clear_btn = f"{wrapper_selector} .MuiAutocomplete-clearIndicator"
is_visible = await page.evaluate(f"""document.querySelector('{clear_btn}') !== null""")
if is_visible:
await page.click(clear_btn)
# 2. Focus and Type into the input
# Note: We use type() which handles the React synthetic event dispatch in extension_client
await page.click(input_selector)
# Type enough to trigger search, or full text
await page.fill(input_selector, value_text)
# 3. Wait for the loading indicator to vanish (if async)
# troubleshooting.md shows `.MuiAutocomplete-popupIndicator` but loading state usually changes icon
await page.wait_for_timeout(500) # Short buffer for React to render options
# 4. Wait for listbox
await page.wait_for_selector('ul[role="listbox"]', timeout=3000)
# 5. Click the option
# Note: Autocomplete options often highlight specific parts of text, so we rely on role="option"
option_selector = f'//ul[@role="listbox"]//li[@role="option"]//text()[contains(., "{value_text}")]/ancestor::li'
# Simplified selector if the above complex path fails
simple_option = f'li[role="option"]:has-text("{value_text}")'
try:
await page.click(simple_option)
except Exception:
# Fallback to pure JS click if selector engine is strict
js_click = f"""
const opts = Array.from(document.querySelectorAll('ul[role="listbox"] li[role="option"]'));
const target = opts.find(el => el.textContent.includes("{value_text}"));
if (target) target.click();
else throw new Error("Option not found");
"""
await page.evaluate(js_click)
async def fill_mui_text(
page: PageLike,
wrapper_selector: str,
value: str
) -> None:
"""
Fills a standard MUI Text Field or Text Area.
"""
# MUI inputs are usually nested inside the wrapper
input_selector = f"{wrapper_selector} input"
# Check if it's a textarea
is_textarea = await page.evaluate(f"document.querySelector('{wrapper_selector} textarea') !== null")
if is_textarea:
input_selector = f"{wrapper_selector} textarea:not([aria-hidden='true'])"
await page.fill(input_selector, value)
async def set_mui_checkbox(
page: PageLike,
wrapper_selector: str,
label_text: str,
checked: bool = True
) -> None:
"""
Toggles a checkbox inside a FormGroup.
Target: <label> ... <span class="MuiCheckbox-root"> ... <span class="MuiTypography-root">Direct</span>
"""
# Find the specific label containing the text
# We click the input's parent span or the label to toggle
# Logic: Find the input associated with the label text
js_toggle = f"""
const labels = Array.from(document.querySelectorAll('{wrapper_selector} label'));
const targetLabel = labels.find(l => l.textContent.includes("{label_text}"));
if (!targetLabel) throw new Error("Label '{label_text}' not found");
const input = targetLabel.querySelector('input[type="checkbox"]');
if (input.checked !== {str(checked).lower()}) {{
input.click();
}}
"""
await page.evaluate(js_toggle)
Part 2: Airtight Extension Integration
The challenge is that worker.js (Service Worker) cannot access the DOM. It must message a Content Script.
2.1 The Content Script (extensions/content.js)
This script executes the actual DOM manipulation. It needs to handle the "React Hack" to ensure fill works on MUI components.
// extensions/content.js
// Listen for messages from the worker
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request.action === "EXECUTE_JS") {
executeSafe(request.code, request.args)
.then(result => sendResponse({ status: "success", result }))
.catch(error => sendResponse({ status: "error", error: error.message }));
// Return true to indicate async response
return true;
}
});
async function executeSafe(code, args) {
// Wrap evaluation in an async function to allow 'await' in the passed string
// logic similar to Playwright's evaluate
const func = new Function("args", `return (async () => { ${code} })();`);
return await func(args);
}
// Helper injected into the page context to handle React Inputs
// This creates a global helper we can call from our Python strings
window.__fillReactInput = (selector, value) => {
const input = document.querySelector(selector);
if (!input) throw new Error(`Element not found: ${selector}`);
// 1. Set value
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value").set;
nativeInputValueSetter.call(input, value);
// 2. Dispatch events
const eventBubbles = { bubbles: true };
input.dispatchEvent(new Event('input', eventBubbles));
input.dispatchEvent(new Event('change', eventBubbles));
// 3. Blur to trigger validation (common in MUI)
input.blur();
};
2.2 The Background Worker (extensions/worker.js)
This acts as the bridge between the WebSocket (Python) and the Content Script (Tab). It handles connection resilience.
// extensions/worker.js
let socket = null;
const WS_URL = "ws://localhost:17373";
function connect() {
socket = new WebSocket(WS_URL);
socket.onopen = () => {
console.log("Connected to Python Guide");
// Identify as extension
socket.send(JSON.stringify({ type: "IDENTIFY", kind: "extension" }));
};
socket.onmessage = async (event) => {
const message = JSON.parse(event.data);
const { id, code, action } = message;
try {
// Get the active tab
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab) {
throw new Error("No active tab found");
}
// Send to content script
const result = await chrome.tabs.sendMessage(tab.id, {
action: "EXECUTE_JS",
code: code
});
if (result.status === "error") {
throw new Error(result.error);
}
socket.send(JSON.stringify({
id,
status: "success",
result: result.result
}));
} catch (err) {
socket.send(JSON.stringify({
id,
status: "error",
error: err.message || "Unknown error in extension worker"
}));
}
};
socket.onclose = () => {
console.log("Disconnected. Reconnecting in 3s...");
setTimeout(connect, 3000);
};
socket.onerror = (err) => {
console.error("WebSocket error:", err);
socket.close();
};
}
// Keep Service Worker alive
chrome.runtime.onStartup.addListener(connect);
chrome.runtime.onInstalled.addListener(connect);
connect();
2.3 Updated Python Client (src/guide/app/browser/extension_client.py)
Update the eval_js and fill methods to leverage the architecture above.
# src/guide/app/browser/extension_client.py (Partial update)
# ... existing imports ...
class ExtensionPage:
# ... existing init ...
async def fill(self, selector: str, value: str) -> None:
"""
Fill using the React-compatible helper defined in content.js.
"""
escaped_selector = self._escape_selector(selector)
escaped_value = value.replace("\\", "\\\\").replace("'", "\\'").replace('"', '\\"')
# We rely on window.__fillReactInput defined in content.js
# Or we inject the logic directly if we want to be self-contained
js_code = f"""
const input = document.querySelector('{escaped_selector}');
if (!input) throw new Error("Element not found: {escaped_selector}");
// React state update hack
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value").set;
setter.call(input, '{escaped_value}');
input.dispatchEvent(new Event('input', {{ bubbles: true }}));
input.dispatchEvent(new Event('change', {{ bubbles: true }}));
input.focus();
input.blur();
"""
await self.eval_js(js_code)
async def click(self, selector: str) -> None:
"""
Robust click that attempts to scroll into view and hit the center.
"""
escaped = self._escape_selector(selector)
js_code = f"""
const el = document.querySelector('{escaped}');
if (!el) throw new Error("Element not found: {escaped}");
el.scrollIntoView({{behavior: 'auto', block: 'center'}});
// Wait a tick for scroll
await new Promise(r => setTimeout(r, 100));
// Dispatch mouse events sequence for MUI/React
const opts = {{ bubbles: true, cancelable: true, view: window }};
el.dispatchEvent(new MouseEvent('mousedown', opts));
el.dispatchEvent(new MouseEvent('mouseup', opts));
el.click();
"""
await self.eval_js(js_code)
# ... existing methods ...
Summary of Integration Logic
- Python (
select_mui_dropdown): Callspage.evaluate(...)(which maps toclient.eval_js). - Extension Client (
eval_js): Sends JSON{ id: "uuid", code: "document.querySelector..." }over WebSocket. - Worker (
worker.js): Receives JSON, finds Active Tab ID, sends messagechrome.tabs.sendMessage. - Content Script (
content.js): Receives message, runsnew Function(...)against the actual DOM. It has access towindowanddocument. It calculates the result or throws an error. - Return Path: Content Script -> Worker (via callback) -> Python (via WebSocket).
This architecture allows your Python mui.py helpers to interact with the complex troubleshooting.md form elements as if they were running locally, bypassing the security restrictions of the Service Worker and the "no-DOM-access" limitation of pure Playwright in Extension mode.
Based on the complex structure of troubleshooting.md (MUI v5, Portals, dynamic IDs, nested wrappers) and the requirement for robust extension support, here is the comprehensive solution.
This solution introduces a specialized library mui.py for the browser elements and a unified "Action Dispatcher" pattern in the extension files to handle the complexity of React events and isolated DOM environments.
1. Robust Browser Elements (src/guide/app/browser/elements/mui.py)
This module abstracts the complexity of Material UI. It identifies elements by their stable data-cy wrappers but interacts with the specific functional DOM nodes (inputs, listboxes) required for the action.
# src/guide/app/browser/elements/mui.py
import logging
import os
from guide.app.browser.types import PageLike
from guide.app.browser.elements.dropdown import click_with_mouse_events
_logger = logging.getLogger(__name__)
# --- Text & Numeric Inputs ---
async def fill_text(page: PageLike, wrapper_selector: str, value: str, is_textarea: bool = False) -> None:
"""
Fills a text input or textarea wrapped in a MUI container.
Handles masking and validation triggers via React event simulation.
Args:
wrapper_selector: Selector for the container (e.g. '[data-cy="contract-form-field-name"]')
value: The string value to type.
is_textarea: Force targeting a textarea element.
"""
# 1. Determine target selector
# MUI structure: <div data-cy="..."> ... <div class="MuiInputBase..."> <input>
tag = "textarea" if is_textarea else "input"
target_selector = f"{wrapper_selector} {tag}:not([type='hidden'])"
# 2. Check for disabled state first
is_disabled = await page.evaluate(f"""
const el = document.querySelector('{target_selector}');
el ? el.disabled || el.readOnly : false
""")
if is_disabled:
_logger.warning(f"Skipping fill for disabled element: {wrapper_selector}")
return
# 3. Use robust fill (handles React state)
# If extension mode, this uses the enhanced fill logic in extension_client
await page.click(target_selector) # Focus
await page.fill(target_selector, value)
# 4. Blur to trigger validation (required for numeric/masked inputs)
await page.evaluate(f"document.querySelector('{target_selector}').blur()")
async def fill_numeric(page: PageLike, wrapper_selector: str, value: str) -> None:
"""
Fills a numeric input. Clears existing masked values (like '0%') before typing.
"""
target_selector = f"{wrapper_selector} input"
# Select all and delete to handle masks/formatting
await page.click(target_selector)
await page.evaluate(f"""
const el = document.querySelector('{target_selector}');
if (el) {{
el.value = '';
el.dispatchEvent(new Event('input', {{ bubbles: true }}));
}}
""")
await page.fill(target_selector, value)
# --- Dropdowns & Autocomplete ---
async def select_single_choice(page: PageLike, wrapper_selector: str, value_text: str) -> None:
"""
Selects from a standard MUI Select (non-searchable dropdown).
Structure: Wrapper -> [role=combobox] -> Portal(ul[role=listbox]) -> li[role=option]
"""
trigger_selector = f"{wrapper_selector} [role='combobox']"
# 1. Open Dropdown
await click_with_mouse_events(page, trigger_selector)
# 2. Wait for Portal
await page.wait_for_selector('ul[role="listbox"]', timeout=3000)
# 3. Select Option (Exact match preferred, fallback to substring)
# Using XPath to break out of the wrapper scope and search the document body
xpath = f"//ul[@role='listbox']//li[@role='option' and .//text()='{value_text}']"
try:
# Try finding exact text match
if hasattr(page, "click_element_with_text"):
# Extension optimized path
await page.click_element_with_text('ul[role="listbox"] li[role="option"]', value_text)
else:
# Playwright CDP path
await page.locator(xpath).first.click()
except Exception:
# Fallback: JavaScript find and click
await page.evaluate(f"""
const opts = Array.from(document.querySelectorAll('ul[role="listbox"] li[role="option"]'));
const target = opts.find(el => el.textContent.trim() === "{value_text}");
if (target) target.click();
else throw new Error("Option '{value_text}' not found in dropdown");
""")
# 4. Wait for close
await page.wait_for_timeout(300)
async def select_autocomplete(page: PageLike, wrapper_selector: str, value_text: str, multi_select: bool = False) -> None:
"""
Selects from a MUI Autocomplete (Search as you type).
Structure: Wrapper -> input[role=combobox] -> Portal(ul[role=listbox])
"""
input_selector = f"{wrapper_selector} input[role='combobox']"
# 1. Clear existing if single select and not empty
if not multi_select:
clear_btn = f"{wrapper_selector} .MuiAutocomplete-clearIndicator"
has_clear = await page.evaluate(f"!!document.querySelector('{clear_btn}')")
if has_clear:
await page.click(clear_btn)
# 2. Type to search
await page.click(input_selector)
await page.fill(input_selector, value_text)
# 3. Wait for options
await page.wait_for_selector('ul[role="listbox"]', timeout=3000)
# 4. Click option (Autocomplete options often wrap text in spans)
# We generally click the first match for the typed text
await page.evaluate(f"""
const opts = Array.from(document.querySelectorAll('ul[role="listbox"] li[role="option"]'));
// Prioritize exact match, then startsWith, then includes
let target = opts.find(el => el.textContent.trim() === "{value_text}");
if (!target) target = opts.find(el => el.textContent.trim().startsWith("{value_text}"));
if (!target) target = opts.find(el => el.textContent.includes("{value_text}"));
if (target) target.click();
else throw new Error("Autocomplete option '{value_text}' not found");
""")
# --- Boolean Inputs ---
async def set_checkbox(page: PageLike, wrapper_selector: str, checked: bool = True) -> None:
"""
Toggles a checkbox. Checks current state before clicking.
Works for both 'terminate for convenience' (span wrapper) and 'attributes' (label wrapper).
"""
input_selector = f"{wrapper_selector} input[type='checkbox']"
# Check current state using JS to avoid stale element handle issues
is_checked = await page.evaluate(f"document.querySelector('{input_selector}').checked")
if is_checked != checked:
# Click the *input* directly if possible, or its parent if input is hidden/zero-size
await click_with_mouse_events(page, input_selector)
async def set_radio_group(page: PageLike, wrapper_selector: str, option_label: str) -> None:
"""
Selects a specific radio button within a group by its label.
"""
# 1. Find the radio input associated with the label text inside the wrapper
# Strategy: Find label containing text -> find radio input
js_click = f"""
const wrapper = document.querySelector('{wrapper_selector}');
const labels = Array.from(wrapper.querySelectorAll('label'));
const targetLabel = labels.find(l => l.textContent.includes("{option_label}"));
if (targetLabel) {{
const radio = targetLabel.querySelector('input[type="radio"]');
if (radio && !radio.checked) radio.click();
}} else {{
throw new Error("Radio option '{option_label}' not found");
}}
"""
await page.evaluate(js_click)
# --- Attachments ---
async def upload_file(page: PageLike, dropzone_selector: str, file_path: str) -> None:
"""
Uploads a file to a dropzone input.
Args:
dropzone_selector: Selector for the input[type=file] (e.g. '#rd-drop-zone-input')
file_path: Absolute path to the file on the machine running the python code.
"""
# Extension Mode Limitation Check
if hasattr(page, "eval_js"): # Is ExtensionPage
_logger.warning("File upload via Extension is limited. Attempting JS DataTransfer simulation.")
# We can't read the file from disk in JS. We can only simulate the event if we had the bytes.
# For this context, we will skip actual upload in extension or throw not supported.
# However, to be "airtight" for what IS possible:
pass
else:
# CDP Mode (Playwright Standard)
await page.locator(dropzone_selector).set_input_files(file_path)
# --- Buttons ---
async def click_button(page: PageLike, selector: str) -> None:
"""
Robust button click that handles MUI ripples and loading states.
"""
# Wait for button to be enabled
await page.wait_for_selector(f"{selector}:not([disabled])", timeout=3000)
await click_with_mouse_events(page, selector)
2. The Content Script (extensions/content.js)
This script acts as the browser-side runtime. It includes a specific ActionDispatcher to cleanly handle different interaction types and the crucial React input hacks.
// extensions/content.js
// --- React Helpers ---
const ReactUtils = {
// Sets value on React controlled inputs by bypassing the wrapper
setNativeValue: (element, value) => {
const valueSetter = Object.getOwnPropertyDescriptor(element, 'value').set;
const prototype = Object.getPrototypeOf(element);
const prototypeValueSetter = Object.getOwnPropertyDescriptor(prototype, 'value').set;
if (valueSetter && valueSetter !== prototypeValueSetter) {
prototypeValueSetter.call(element, value);
} else {
valueSetter.call(element, value);
}
},
// Dispatches full suite of events to ensure UI updates
dispatchEvents: (element) => {
element.dispatchEvent(new Event('input', { bubbles: true }));
element.dispatchEvent(new Event('change', { bubbles: true }));
element.blur(); // Often needed for validation
}
};
// --- Action Implementations ---
const Actions = {
FILL: async ({ selector, value }) => {
const el = document.querySelector(selector);
if (!el) throw new Error(`Element not found: ${selector}`);
el.focus();
ReactUtils.setNativeValue(el, value);
ReactUtils.dispatchEvents(el);
return true;
},
CLICK: async ({ selector }) => {
const el = document.querySelector(selector);
if (!el) throw new Error(`Element not found: ${selector}`);
// Scroll logic
el.scrollIntoView({ behavior: 'auto', block: 'center' });
// Synthetic Mouse Events (MUI/React often needs these)
const opts = { bubbles: true, cancelable: true, view: window };
el.dispatchEvent(new MouseEvent('mousedown', opts));
el.dispatchEvent(new MouseEvent('mouseup', opts));
el.click();
return true;
},
CLICK_TEXT: async ({ selector, text }) => {
// Finds an element matching selector that contains text
const elements = Array.from(document.querySelectorAll(selector));
const target = elements.find(e => e.textContent.includes(text));
if (!target) throw new Error(`Element ${selector} with text "${text}" not found`);
target.scrollIntoView({ behavior: 'auto', block: 'center' });
target.click();
return true;
},
EVAL: async ({ code, args }) => {
// Safe evaluation of custom JS strings passed from Python
const func = new Function("args", `return (async () => { ${code} })();`);
return await func(args);
}
};
// --- Message Listener ---
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
const action = Actions[request.action];
if (action) {
action(request.payload)
.then(result => sendResponse({ status: "success", result }))
.catch(error => sendResponse({ status: "error", error: error.message }));
return true; // Async response
}
});
3. The Extension Worker (extensions/worker.js)
The airtight bridge. It handles the WebSocket connection, identification, and robust error handling if the active tab is missing or the content script hasn't loaded.
// extensions/worker.js
const WS_URL = "ws://localhost:17373";
let socket = null;
let reconnectTimer = null;
function connect() {
socket = new WebSocket(WS_URL);
socket.onopen = () => {
console.log("[Guide] Connected to Host");
socket.send(JSON.stringify({ type: "IDENTIFY", kind: "extension" }));
if (reconnectTimer) clearInterval(reconnectTimer);
};
socket.onmessage = async (event) => {
const msg = JSON.parse(event.data);
const { id, action, payload } = msg;
try {
// 1. Get Active Tab
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab) throw new Error("No active tab found");
// 2. Relay to Content Script
// We explicitly map Python generic requests to specific Content Script actions
// If code is provided, it's an EVAL, otherwise mapped action
const targetAction = msg.code ? "EVAL" : (msg.action || "EVAL");
const targetPayload = msg.code ? { code: msg.code } : payload;
const response = await chrome.tabs.sendMessage(tab.id, {
action: targetAction,
payload: targetPayload
});
if (!response) throw new Error("No response from content script (is page loaded?)");
if (response.status === "error") throw new Error(response.error);
// 3. Success Response
socket.send(JSON.stringify({ id, status: "success", result: response.result }));
} catch (err) {
console.error("[Guide] Execution Error:", err);
socket.send(JSON.stringify({ id, status: "error", error: err.message }));
}
};
socket.onclose = () => {
console.log("[Guide] Disconnected. Retrying...");
socket = null;
reconnectTimer = setTimeout(connect, 3000);
};
socket.onerror = (err) => {
console.error("[Guide] Socket Error:", err);
socket.close();
};
}
// Ensure worker stays alive and connects immediately
chrome.runtime.onStartup.addListener(connect);
chrome.runtime.onInstalled.addListener(connect);
connect();
4. Integration Updates (src/guide/app/browser/extension_client.py)
Update the Python client to send structured messages that match the new Actions map in content.js.
# src/guide/app/browser/extension_client.py (Relevant updates)
class ExtensionPage:
# ... init ...
async def fill(self, selector: str, value: str) -> None:
"""
Structured fill command sent to extension.
"""
escaped_selector = self._escape_selector(selector)
# We send a structured object now, not raw JS, for standard actions
await self._send_command("FILL", {"selector": escaped_selector, "value": value})
async def click(self, selector: str) -> None:
escaped_selector = self._escape_selector(selector)
await self._send_command("CLICK", {"selector": escaped_selector})
async def click_element_with_text(self, selector: str, text: str, timeout: int = 5000) -> None:
escaped_selector = self._escape_selector(selector)
await self._send_command("CLICK_TEXT", {"selector": escaped_selector, "text": text})
async def _send_command(self, action: str, payload: dict[str, Any]) -> Any:
"""
Helper to send structured commands.
"""
request_id = str(uuid.uuid4())
future: asyncio.Future[JSONValue] = asyncio.Future()
self._pending[request_id] = future
message = {
"id": request_id,
"action": action, # Maps to content.js Actions
"payload": payload
}
await self._client.send_message(message)
return await future
# eval_js remains for custom scripts
async def eval_js(self, code: str, await_promise: bool = True) -> JSONValue:
request_id = str(uuid.uuid4())
future: asyncio.Future[JSONValue] = asyncio.Future()
self._pending[request_id] = future
message = {
"id": request_id,
"code": code, # Worker detects 'code' and maps to EVAL
}
await self._client.send_message(message)
return await future