Add frame-aware locate for cross-origin iframe steps
Selectors were only resolved against the top document, so steps targeting the Lucid WooCommerce checkout (rendered in a lucidtrading.com iframe under the dash.lucidtrading.com app) never matched — e.g. input#coupon_code_field. Add an optional `frame` on click/type/waitFor naming the <iframe> element. The extension measures the iframe in the top frame (pageFrameOffset), finds the element across child frames (pageLocate `relative` mode), and composes the two into desktop coordinates; the Python side is unchanged since the result keeps the same shape. Without `frame`, every path behaves as before. Point the Lucid checkout steps at the frame and harden the coupon selectors (the exact field id is still unconfirmed against the live iframe). Extension 0.13.0, runner 0.19.0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
016f121f40
commit
66c99c5499
+9
-3
@@ -117,6 +117,7 @@ def perform(
|
|||||||
url: str = "",
|
url: str = "",
|
||||||
open_url: str = "",
|
open_url: str = "",
|
||||||
signed_out: str = "",
|
signed_out: str = "",
|
||||||
|
frame: str = "",
|
||||||
text: str | None = None,
|
text: str | None = None,
|
||||||
clear: bool = False,
|
clear: bool = False,
|
||||||
scale: float | None = None,
|
scale: float | None = None,
|
||||||
@@ -141,7 +142,12 @@ def perform(
|
|||||||
rng = rng or random.Random()
|
rng = rng or random.Random()
|
||||||
say = report or (lambda _m: None)
|
say = report or (lambda _m: None)
|
||||||
|
|
||||||
found = dash.locate(selector, index, url, timeout, open_url)
|
# `frame` names the <iframe> the element lives in; the extension resolves the
|
||||||
|
# selector inside it and offsets the coordinates back. Absent for the common
|
||||||
|
# top-document case, and then locate behaves exactly as before.
|
||||||
|
frame_opts = {"frame": frame} if frame else None
|
||||||
|
|
||||||
|
found = dash.locate(selector, index, url, timeout, open_url, options=frame_opts)
|
||||||
|
|
||||||
# Before any check that assumes we are on the real page.
|
# Before any check that assumes we are on the real page.
|
||||||
check_signed_in(found, signed_out)
|
check_signed_in(found, signed_out)
|
||||||
@@ -153,7 +159,7 @@ def perform(
|
|||||||
deadline = time.time() + COVERED_SETTLE_SECONDS
|
deadline = time.time() + COVERED_SETTLE_SECONDS
|
||||||
while found.get("covered") and time.time() < deadline:
|
while found.get("covered") and time.time() < deadline:
|
||||||
time.sleep(COVERED_POLL_SECONDS)
|
time.sleep(COVERED_POLL_SECONDS)
|
||||||
found = dash.locate(selector, index, url, timeout, open_url)
|
found = dash.locate(selector, index, url, timeout, open_url, options=frame_opts)
|
||||||
check_signed_in(found, signed_out)
|
check_signed_in(found, signed_out)
|
||||||
if not found.get("covered"):
|
if not found.get("covered"):
|
||||||
say("it cleared")
|
say("it cleared")
|
||||||
@@ -224,7 +230,7 @@ def perform(
|
|||||||
|
|
||||||
# Typing into a field that never took focus, or that ignores the input, fails
|
# Typing into a field that never took focus, or that ignores the input, fails
|
||||||
# silently and is otherwise indistinguishable from success.
|
# silently and is otherwise indistinguishable from success.
|
||||||
after = dash.locate(selector, index, url, timeout, open_url)
|
after = dash.locate(selector, index, url, timeout, open_url, options=frame_opts)
|
||||||
check_signed_in(after, signed_out)
|
check_signed_in(after, signed_out)
|
||||||
got = after.get("value")
|
got = after.get("value")
|
||||||
if got is None:
|
if got is None:
|
||||||
|
|||||||
+11
-2
@@ -34,7 +34,7 @@ HEARTBEAT_SECONDS = 2.0
|
|||||||
# Bumped whenever the step vocabulary or the locate protocol changes. Reported in
|
# Bumped whenever the step vocabulary or the locate protocol changes. Reported in
|
||||||
# the heartbeat so the dashboard can say "restart your runner" instead of letting
|
# the heartbeat so the dashboard can say "restart your runner" instead of letting
|
||||||
# a stale process fail on a step type it has never heard of.
|
# a stale process fail on a step type it has never heard of.
|
||||||
VERSION = "0.18.0"
|
VERSION = "0.19.0"
|
||||||
|
|
||||||
# Shared with the heartbeat thread: whether a run is currently executing.
|
# Shared with the heartbeat thread: whether a run is currently executing.
|
||||||
_busy = threading.Event()
|
_busy = threading.Event()
|
||||||
@@ -97,11 +97,19 @@ def run_one_step(dash: Dashboard, step: dict, opts, rng, report, run_id: int | N
|
|||||||
if run_id is not None and run_status(dash, run_id) == "cancelled":
|
if run_id is not None and run_status(dash, run_id) == "cancelled":
|
||||||
raise actions.StepError("cancelled while waiting")
|
raise actions.StepError("cancelled while waiting")
|
||||||
|
|
||||||
|
# A frame-scoped gate resolves the selector inside the named <iframe>;
|
||||||
|
# `hidden` adds the visibility probe. Either can be set independently.
|
||||||
|
loc_opts: dict = {}
|
||||||
|
if by_sight:
|
||||||
|
loc_opts["op"] = "visible"
|
||||||
|
if step.get("frame"):
|
||||||
|
loc_opts["frame"] = step["frame"]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
seen = dash.locate(selector, int(step.get("index", 0)),
|
seen = dash.locate(selector, int(step.get("index", 0)),
|
||||||
step.get("urlPattern", "") or opts.url,
|
step.get("urlPattern", "") or opts.url,
|
||||||
opts.timeout, step.get("openUrl", "") or "",
|
opts.timeout, step.get("openUrl", "") or "",
|
||||||
options={"op": "visible"} if by_sight else None)
|
options=loc_opts or None)
|
||||||
if by_sight:
|
if by_sight:
|
||||||
# An extension too old for the probe quietly does an ordinary
|
# An extension too old for the probe quietly does an ordinary
|
||||||
# locate instead, whose result has no `visible` key — reading
|
# locate instead, whose result has no `visible` key — reading
|
||||||
@@ -190,6 +198,7 @@ def run_one_step(dash: Dashboard, step: dict, opts, rng, report, run_id: int | N
|
|||||||
url=step.get("urlPattern", "") or opts.url,
|
url=step.get("urlPattern", "") or opts.url,
|
||||||
open_url=step.get("openUrl", "") or "",
|
open_url=step.get("openUrl", "") or "",
|
||||||
signed_out=step.get("signedOut", "") or "",
|
signed_out=step.get("signedOut", "") or "",
|
||||||
|
frame=step.get("frame", "") or "",
|
||||||
text=step.get("text"),
|
text=step.get("text"),
|
||||||
clear=bool(step.get("clear")),
|
clear=bool(step.get("clear")),
|
||||||
scale=opts.scale,
|
scale=opts.scale,
|
||||||
|
|||||||
+155
-4
@@ -188,8 +188,14 @@ function detectBrowser() {
|
|||||||
|
|
||||||
// ── Locate: turn a CSS selector into desktop coordinates ────────────────────
|
// ── Locate: turn a CSS selector into desktop coordinates ────────────────────
|
||||||
|
|
||||||
/** Runs in the page. Scrolls the element into view, then reports where it ended up. */
|
/** Runs in the page. Scrolls the element into view, then reports where it ended up.
|
||||||
function pageLocate(selector, index) {
|
* `relative` is set when this runs inside a child frame: the frame's own
|
||||||
|
* window.screenX and getBoundingClientRect are relative to that frame, not the
|
||||||
|
* OS screen, so the desktop coordinate cannot be computed here — a cross-origin
|
||||||
|
* child cannot read where it sits inside its parent. In that mode it returns the
|
||||||
|
* element's position within THIS frame's viewport and leaves the composition to
|
||||||
|
* the background script, which adds the parent <iframe>'s offset (pageFrameOffset). */
|
||||||
|
function pageLocate(selector, index, relative) {
|
||||||
const matches = document.querySelectorAll(selector);
|
const matches = document.querySelectorAll(selector);
|
||||||
if (matches.length === 0) return { error: `No element matches ${selector}` };
|
if (matches.length === 0) return { error: `No element matches ${selector}` };
|
||||||
const el = matches[index];
|
const el = matches[index];
|
||||||
@@ -234,7 +240,7 @@ function pageLocate(selector, index) {
|
|||||||
const isField = el.tagName === 'INPUT' || el.tagName === 'TEXTAREA';
|
const isField = el.tagName === 'INPUT' || el.tagName === 'TEXTAREA';
|
||||||
const value = isField ? el.value : (el.isContentEditable ? el.innerText : null);
|
const value = isField ? el.value : (el.isContentEditable ? el.innerText : null);
|
||||||
|
|
||||||
return {
|
const common = {
|
||||||
tag: el.tagName.toLowerCase(),
|
tag: el.tagName.toLowerCase(),
|
||||||
text: (el.textContent || '').trim().slice(0, 80),
|
text: (el.textContent || '').trim().slice(0, 80),
|
||||||
matchCount: matches.length,
|
matchCount: matches.length,
|
||||||
@@ -244,6 +250,18 @@ function pageLocate(selector, index) {
|
|||||||
covered: !!covered,
|
covered: !!covered,
|
||||||
coveredBy: covered ? describeNode(atPoint) : null,
|
coveredBy: covered ? describeNode(atPoint) : null,
|
||||||
viewport: { x: r.left, y: r.top, width: r.width, height: r.height },
|
viewport: { x: r.left, y: r.top, width: r.width, height: r.height },
|
||||||
|
url: location.href,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Inside a child frame the desktop coordinate isn't ours to compute — hand
|
||||||
|
// back the element's centre within this frame's viewport and let the caller
|
||||||
|
// add the parent <iframe>'s on-screen offset.
|
||||||
|
if (relative) {
|
||||||
|
return { ...common, frameCenter: { x: cx, y: cy } };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...common,
|
||||||
// Desktop coordinates of the element's centre, in CSS pixels.
|
// Desktop coordinates of the element's centre, in CSS pixels.
|
||||||
screen: { x: window.screenX + cx, y: window.screenY + chromeHeight + cy },
|
screen: { x: window.screenX + cx, y: window.screenY + chromeHeight + cy },
|
||||||
// Lets the Python side work out whether the OS uses a different pixel
|
// Lets the Python side work out whether the OS uses a different pixel
|
||||||
@@ -251,7 +269,36 @@ function pageLocate(selector, index) {
|
|||||||
screenSize: { width: window.screen.width, height: window.screen.height },
|
screenSize: { width: window.screen.width, height: window.screen.height },
|
||||||
devicePixelRatio: window.devicePixelRatio,
|
devicePixelRatio: window.devicePixelRatio,
|
||||||
chromeHeight,
|
chromeHeight,
|
||||||
url: location.href,
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs in the TOP frame. Measures where a child <iframe> sits and reports the
|
||||||
|
* top window's screen origin, so the background script can turn a frame-relative
|
||||||
|
* element position (pageLocate's `relative` mode) into desktop coordinates. This
|
||||||
|
* cannot be done from inside the child: a cross-origin frame can read neither its
|
||||||
|
* own offset within the parent nor the top window's screenX/screenY. */
|
||||||
|
function pageFrameOffset(frameSelector) {
|
||||||
|
const frame = document.querySelector(frameSelector);
|
||||||
|
if (!frame) return { error: `No element matches ${frameSelector}` };
|
||||||
|
if (frame.tagName !== 'IFRAME') {
|
||||||
|
return { error: `${frameSelector} is a <${frame.tagName.toLowerCase()}>, not an <iframe>` };
|
||||||
|
}
|
||||||
|
frame.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
|
||||||
|
const r = frame.getBoundingClientRect();
|
||||||
|
if (r.width === 0 || r.height === 0) {
|
||||||
|
return { error: `Frame ${frameSelector} has zero size (hidden or display:none)` };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
rect: { left: r.left, top: r.top, width: r.width, height: r.height },
|
||||||
|
src: frame.getAttribute('src') || frame.src || '',
|
||||||
|
name: frame.getAttribute('name') || '',
|
||||||
|
screenX: window.screenX,
|
||||||
|
screenY: window.screenY,
|
||||||
|
chromeHeight: window.outerHeight - window.innerHeight,
|
||||||
|
screenSize: { width: window.screen.width, height: window.screen.height },
|
||||||
|
devicePixelRatio: window.devicePixelRatio,
|
||||||
|
innerWidth: window.innerWidth,
|
||||||
|
innerHeight: window.innerHeight,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -758,6 +805,110 @@ async function serveLocateRequest(cfg) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A step can target an element inside a cross-origin <iframe> — the
|
||||||
|
// WooCommerce checkout modal is one. A selector run against the top
|
||||||
|
// document never matches it, so resolve it in two parts: measure where the
|
||||||
|
// <iframe> sits (top frame, pageFrameOffset) and find the element inside
|
||||||
|
// whichever child frame holds it (all frames, pageLocate in `relative`
|
||||||
|
// mode), then add the two together to reach desktop coordinates.
|
||||||
|
if (request.options && request.options.frame) {
|
||||||
|
const frameSel = request.options.frame;
|
||||||
|
|
||||||
|
// A frame-scoped visibility probe: no coordinates needed, just report
|
||||||
|
// whether the element is there and rendered in some child frame.
|
||||||
|
if (request.options.op === 'visible') {
|
||||||
|
const injections = await chrome.scripting.executeScript({
|
||||||
|
target: { tabId: tab.id, allFrames: true },
|
||||||
|
func: pageVisible,
|
||||||
|
args: [request.selector, request.index || 0],
|
||||||
|
});
|
||||||
|
const hits = injections.filter((i) => i.frameId !== 0 && i.result);
|
||||||
|
const present = hits.find((i) => i.result.present);
|
||||||
|
const chosen = present ? present.result
|
||||||
|
: (hits[0]?.result
|
||||||
|
|| { op: 'visible', present: false, visible: false, matchCount: 0, reason: 'no subframe matched' });
|
||||||
|
result = { ...chosen, tabId: tab.id, windowId: tab.windowId, browser: detectBrowser() };
|
||||||
|
await postLocateResult(cfg, request.id, result, null);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Where the iframe sits, and the top window's screen origin.
|
||||||
|
const [offsetInj] = await chrome.scripting.executeScript({
|
||||||
|
target: { tabId: tab.id },
|
||||||
|
func: pageFrameOffset,
|
||||||
|
args: [frameSel],
|
||||||
|
});
|
||||||
|
const offset = offsetInj?.result;
|
||||||
|
if (!offset) throw new Error('Frame-offset injection returned nothing');
|
||||||
|
if (offset.error) throw new Error(offset.error);
|
||||||
|
|
||||||
|
// The element, measured inside whatever child frame holds it. Retry
|
||||||
|
// while the checkout iframe is still mounting — same budget as the
|
||||||
|
// top-frame path below.
|
||||||
|
const frameDeadline = Date.now() + (opened ? 8000 : 2500);
|
||||||
|
let hit;
|
||||||
|
for (;;) {
|
||||||
|
const injections = await chrome.scripting.executeScript({
|
||||||
|
target: { tabId: tab.id, allFrames: true },
|
||||||
|
func: pageLocate,
|
||||||
|
args: [request.selector, request.index || 0, true],
|
||||||
|
});
|
||||||
|
// Only child frames; the top frame is handled by the normal path.
|
||||||
|
const hits = injections.filter((i) => i.frameId !== 0 && i.result && !i.result.error);
|
||||||
|
if (hits.length === 1) { hit = hits[0].result; break; }
|
||||||
|
if (hits.length > 1) {
|
||||||
|
throw new Error(
|
||||||
|
`Selector ${request.selector} matched in ${hits.length} subframes `
|
||||||
|
+ `(${hits.map((h) => h.result.url).join(', ')}) — narrow it`);
|
||||||
|
}
|
||||||
|
// Phrased with "No element matches" so the runner treats it as
|
||||||
|
// absent (and skipIfNotFound can skip it), same as the top frame.
|
||||||
|
if (Date.now() >= frameDeadline) {
|
||||||
|
throw new Error(`No element matches ${request.selector} in frame ${frameSel}`);
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, 350));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compose: the element's centre in the top viewport is where the
|
||||||
|
// iframe is plus where the element is inside it. From there the screen
|
||||||
|
// origin and browser-chrome height (both the top window's) give desktop
|
||||||
|
// pixels, exactly as pageLocate does for a top-level element.
|
||||||
|
const tx = offset.rect.left + hit.frameCenter.x;
|
||||||
|
const ty = offset.rect.top + hit.frameCenter.y;
|
||||||
|
if (tx < 0 || ty < 0 || tx >= offset.innerWidth || ty >= offset.innerHeight) {
|
||||||
|
throw new Error(
|
||||||
|
`Element ${request.selector} resolves outside the viewport `
|
||||||
|
+ `(frame ${frameSel} is partly off-screen)`);
|
||||||
|
}
|
||||||
|
result = {
|
||||||
|
tag: hit.tag,
|
||||||
|
text: hit.text,
|
||||||
|
matchCount: hit.matchCount,
|
||||||
|
value: hit.value,
|
||||||
|
editable: hit.editable,
|
||||||
|
inputType: hit.inputType,
|
||||||
|
covered: hit.covered,
|
||||||
|
coveredBy: hit.coveredBy,
|
||||||
|
url: hit.url,
|
||||||
|
viewport: {
|
||||||
|
x: offset.rect.left + hit.viewport.x,
|
||||||
|
y: offset.rect.top + hit.viewport.y,
|
||||||
|
width: hit.viewport.width,
|
||||||
|
height: hit.viewport.height,
|
||||||
|
},
|
||||||
|
screen: { x: offset.screenX + tx, y: offset.screenY + offset.chromeHeight + ty },
|
||||||
|
screenSize: offset.screenSize,
|
||||||
|
devicePixelRatio: offset.devicePixelRatio,
|
||||||
|
chromeHeight: offset.chromeHeight,
|
||||||
|
tabId: tab.id,
|
||||||
|
windowId: tab.windowId,
|
||||||
|
openedTab: opened,
|
||||||
|
browser: detectBrowser(),
|
||||||
|
};
|
||||||
|
await postLocateResult(cfg, request.id, result, null);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// A visibility probe is a read, not a lookup: it reports on the element
|
// A visibility probe is a read, not a lookup: it reports on the element
|
||||||
// instead of failing when there isn't one, so it never retries and never
|
// instead of failing when there isn't one, so it never retries and never
|
||||||
// errors on a missing selector — being missing is the answer.
|
// errors on a missing selector — being missing is the answer.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "AutoFirmer Capture",
|
"name": "AutoFirmer Capture",
|
||||||
"version": "0.12.0",
|
"version": "0.13.0",
|
||||||
|
|
||||||
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxdbOMRR+58a9dHlbIKtlALtS3E4eltBGqfTnUc0lzJuWj5ZA4+5YFSdUnxv5N1ogxwpJUGkUV3MnKJuZnTekCmsNwjCy41a20Xtc7jQLHrIBZkesB6cB+d0l9Vo8M6EC3ZMD8Iv6MdAalU3GZh1Hn9vm24NjJ+xUT1qNIVHEcNUIb9evYobR/XHv+JAp6bOFnaLM+3wpyYKe4efMhsz0F86u9thRNhQnraTqUWfnKNpsHPK2zivaYHrziEEWX7Y3EsMHkf2FRhkUe7IDoxeJM49DBToTo9sAJUkcgLeGCGZLo7H3mcxyn/zERYR+rD3k5xdeBtjoZHXFx2+FanLb9wIDAQAB",
|
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxdbOMRR+58a9dHlbIKtlALtS3E4eltBGqfTnUc0lzJuWj5ZA4+5YFSdUnxv5N1ogxwpJUGkUV3MnKJuZnTekCmsNwjCy41a20Xtc7jQLHrIBZkesB6cB+d0l9Vo8M6EC3ZMD8Iv6MdAalU3GZh1Hn9vm24NjJ+xUT1qNIVHEcNUIb9evYobR/XHv+JAp6bOFnaLM+3wpyYKe4efMhsz0F86u9thRNhQnraTqUWfnKNpsHPK2zivaYHrziEEWX7Y3EsMHkf2FRhkUe7IDoxeJM49DBToTo9sAJUkcgLeGCGZLo7H3mcxyn/zERYR+rD3k5xdeBtjoZHXFx2+FanLb9wIDAQAB",
|
||||||
"update_url": "https://ext.juicerroom.com/updates.xml",
|
"update_url": "https://ext.juicerroom.com/updates.xml",
|
||||||
|
|||||||
+21
-8
@@ -15,8 +15,14 @@ export type AutomationStep =
|
|||||||
// failing the run — for things that only sometimes appear, like a cookie
|
// failing the run — for things that only sometimes appear, like a cookie
|
||||||
// banner or a confirmation modal. Only absence is tolerated: an element that
|
// banner or a confirmation modal. Only absence is tolerated: an element that
|
||||||
// is present but covered or off-screen still fails.
|
// is present but covered or off-screen still fails.
|
||||||
| { action: 'click'; selector: string; index?: number; skipIfNotFound?: boolean; urlPattern?: string; label?: string }
|
// `frame` targets an element inside a cross-origin <iframe> (e.g. the
|
||||||
| { action: 'type'; selector: string; text: string; index?: number; clear?: boolean; skipIfNotFound?: boolean; urlPattern?: string; label?: string }
|
// WooCommerce checkout modal). Its value is a selector for the <iframe>
|
||||||
|
// element in the top document; the step's own `selector` is then resolved
|
||||||
|
// inside that frame and its coordinates offset back to the page. Without it a
|
||||||
|
// selector only ever matches the top document. Needs extension 0.13.0+ and
|
||||||
|
// runner 0.19.0+.
|
||||||
|
| { action: 'click'; selector: string; index?: number; skipIfNotFound?: boolean; urlPattern?: string; frame?: string; label?: string }
|
||||||
|
| { action: 'type'; selector: string; text: string; index?: number; clear?: boolean; skipIfNotFound?: boolean; urlPattern?: string; frame?: string; label?: string }
|
||||||
| { action: 'wait'; seconds: number; label?: string }
|
| { action: 'wait'; seconds: number; label?: string }
|
||||||
// Point the firm's tab at a page. Omit `url` to use the firm's own. Skipped
|
// Point the firm's tab at a page. Omit `url` to use the firm's own. Skipped
|
||||||
// when the tab is already there, so it doesn't reload and lose page state.
|
// when the tab is already there, so it doesn't reload and lose page state.
|
||||||
@@ -32,7 +38,7 @@ export type AutomationStep =
|
|||||||
// content-visibility, or collapsed to zero size all end the wait. It says
|
// content-visibility, or collapsed to zero size all end the wait. It says
|
||||||
// nothing about being scrolled off-screen or covered by an overlay: those
|
// nothing about being scrolled off-screen or covered by an overlay: those
|
||||||
// are still on the page. Needs extension 0.12.0+ and runner 0.18.0+.
|
// are still on the page. Needs extension 0.12.0+ and runner 0.18.0+.
|
||||||
| { action: 'waitFor'; selector: string; index?: number; absent?: boolean; hidden?: boolean; timeoutSeconds?: number; label?: string }
|
| { action: 'waitFor'; selector: string; index?: number; absent?: boolean; hidden?: boolean; timeoutSeconds?: number; frame?: string; label?: string }
|
||||||
// Scroll until the page stops adding elements matching `selector`, for lists
|
// Scroll until the page stops adding elements matching `selector`, for lists
|
||||||
// that load progressively. `containerSelector` names the scrolling element
|
// that load progressively. `containerSelector` names the scrolling element
|
||||||
// when the automatic guess is wrong — these lists usually scroll inside a div
|
// when the automatic guess is wrong — these lists usually scroll inside a div
|
||||||
@@ -271,11 +277,18 @@ export const FIRMS: Firm[] = [
|
|||||||
{ action: 'click', selector: '.slide:nth-child(2) .cta-btn', label: 'Open' },
|
{ action: 'click', selector: '.slide:nth-child(2) .cta-btn', label: 'Open' },
|
||||||
{ action: 'click', selector: '.platform-btn:first-child', label: 'CQG' },
|
{ action: 'click', selector: '.platform-btn:first-child', label: 'CQG' },
|
||||||
|
|
||||||
{ action: 'click', selector: 'input#coupon_code_field', label: 'Click on coupon code'},
|
// The checkout renders in a WooCommerce iframe on
|
||||||
{ action: 'type', selector: 'input#coupon_code_field', text: 'THEPROPFARM'},
|
// lucidtrading.com (parent app is dash.lucidtrading.com), so
|
||||||
{ action: 'click', selector: 'input#coupon_code_field + button'},
|
// every step here targets the frame. The coupon field id
|
||||||
{ action: 'click', selector: '.validate-required:not(.woocommerce-validated)', label: 'Agree to terms' },
|
// varies between WooCommerce's default (#coupon_code /
|
||||||
{ action: 'click', selector: '.validate-required:not(.woocommerce-validated)', label: 'Agree to terms 2' },
|
// name="coupon_code") and this template's custom id; list
|
||||||
|
// both so one of them matches. Apply is WooCommerce's
|
||||||
|
// named submit button, not a positional sibling.
|
||||||
|
{ action: 'click', selector: 'input#coupon_code, input[name="coupon_code"], input#coupon_code_field', frame: '#woocommerceModal iframe', label: 'Focus coupon field' },
|
||||||
|
{ action: 'type', selector: 'input#coupon_code, input[name="coupon_code"], input#coupon_code_field', frame: '#woocommerceModal iframe', text: 'THEPROPFARM', label: 'Enter coupon' },
|
||||||
|
{ action: 'click', selector: 'button[name="apply_coupon"], input[name="apply_coupon"]', frame: '#woocommerceModal iframe', label: 'Apply coupon' },
|
||||||
|
{ action: 'click', selector: '.validate-required:not(.woocommerce-validated)', frame: '#woocommerceModal iframe', label: 'Agree to terms' },
|
||||||
|
{ action: 'click', selector: '.validate-required:not(.woocommerce-validated)', frame: '#woocommerceModal iframe', label: 'Agree to terms 2' },
|
||||||
|
|
||||||
{ action: 'waitFor', selector: '#woocommerceModal', absent: true, hidden: true, timeoutSeconds: 30, label: 'Wait for the modal to close' },
|
{ action: 'waitFor', selector: '#woocommerceModal', absent: true, hidden: true, timeoutSeconds: 30, label: 'Wait for the modal to close' },
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user