Replace the single hardcoded signed-out check with a guards list on each firm: any guard can trip mid-run via a URL substring (checked on every step) or a selector probe (checked when a step fails). Each guard defines recovery steps or a human-wait fallback, a per-run trigger cap, and a resume policy — 'continue' retries the tripped step, 'restart' rewinds the run to step 1. Runner bumped to 0.20.0; the server still ships the legacy signedOut/authSteps shape for stale runners, and a new runner synthesizes a guard from a legacy payload. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
567 lines
29 KiB
TypeScript
567 lines
29 KiB
TypeScript
// Automations live inside the firm whose site they drive. The dashboard renders
|
||
// a tab per firm and a button per automation within it; the Python runner claims
|
||
// a queued run, resolves each selector through the extension, and drives the real
|
||
// mouse.
|
||
//
|
||
// Adding a button means adding an entry to that firm's `automations` — nothing in
|
||
// the page, the API or the runner changes.
|
||
//
|
||
// Adding a NEW FIRM also needs its host added to extension/manifest.json
|
||
// host_permissions, and the extension reloaded. Without that the extension is not
|
||
// permitted to read that site and every step fails to locate.
|
||
|
||
export type AutomationStep =
|
||
// `skipIfNotFound` skips the step when the element isn't on the page, instead of
|
||
// failing the run — for things that only sometimes appear, like a cookie
|
||
// banner or a confirmation modal. Only absence is tolerated: an element that
|
||
// is present but covered or off-screen still fails.
|
||
// `frame` targets an element inside a cross-origin <iframe> (e.g. the
|
||
// 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.14.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 }
|
||
// 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.
|
||
| { action: 'navigate'; url?: string; label?: string }
|
||
// Block until `selector` exists — or, with `absent`, until it is gone from the
|
||
// DOM. Nothing is clicked or typed; this is a gate, for conditions something
|
||
// outside the run has to satisfy. Note `absent` alone means removed, not
|
||
// merely hidden: an element still in the DOM with display:none keeps
|
||
// matching, and the gate would sit there until it timed out.
|
||
//
|
||
// `hidden` (only meaningful with `absent`) widens the gate to "we cannot see
|
||
// it any more" — removed, display:none, visibility:hidden, opacity:0,
|
||
// 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
|
||
// 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; frame?: string; label?: string }
|
||
// Scroll until the page stops adding elements matching `selector`, for lists
|
||
// that load progressively. `containerSelector` names the scrolling element
|
||
// when the automatic guess is wrong — these lists usually scroll inside a div
|
||
// rather than the window, and scrolling the document does nothing.
|
||
| {
|
||
action: 'scrollToLoad';
|
||
selector: string;
|
||
containerSelector?: string;
|
||
maxScrolls?: number;
|
||
settleMs?: number;
|
||
label?: string;
|
||
}
|
||
// Solve the page's captcha through the 2captcha API.
|
||
//
|
||
// Replaces the old pair of steps that clicked the 2captcha *extension's* own
|
||
// "solve" button and waited for it to go green. That needed a second
|
||
// extension installed and signed in on the machine, put a real mouse click on
|
||
// a button whose position and markup are 2captcha's to change, and gave the
|
||
// run no way to tell "still solving" from "the extension is not there".
|
||
//
|
||
// This reads the widget's parameters out of the page, has the dashboard ask
|
||
// 2captcha for a token, and hands the token back to the page's own callback.
|
||
// No mouse involved.
|
||
//
|
||
// `provider` is normally left off: the widget is detected. Set it when a page
|
||
// carries more than one kind. `selector` likewise scopes detection to a
|
||
// container when several widgets are on the page at once.
|
||
| {
|
||
action: 'solveCaptcha';
|
||
provider?: 'turnstile' | 'recaptcha' | 'hcaptcha';
|
||
selector?: string;
|
||
timeoutSeconds?: number;
|
||
label?: string;
|
||
}
|
||
// Run `steps` several times over. `times` fixes the count here; `timesFrom`
|
||
// takes it from an input the user fills in on the dashboard. The block is
|
||
// unrolled before the runner ever sees it — see resolveSteps.
|
||
| { action: 'repeat'; times?: number; timesFrom?: string; steps: AutomationStep[]; label?: string };
|
||
|
||
/**
|
||
* NOTE: the `FIRMS` array below is the AutoBuyer's list of prop-firm *websites*
|
||
* to click through. It is unrelated to the firm catalog in lib/firm-catalog.ts,
|
||
* which is the Tradovate side. Linking the two is future work.
|
||
* A number the user supplies on the dashboard before starting a run. */
|
||
export interface AutomationInput {
|
||
id: string;
|
||
label: string;
|
||
default: number;
|
||
min: number;
|
||
max: number;
|
||
}
|
||
|
||
export type RunInputs = Record<string, number>;
|
||
|
||
/** Unrolling a repeat multiplies steps, and each one can be a purchase. Cap the
|
||
* expansion so a bad input can't queue a thousand clicks. */
|
||
const MAX_RESOLVED_STEPS = 400;
|
||
const MAX_REPEAT_DEPTH = 3;
|
||
|
||
export interface Automation {
|
||
id: string;
|
||
label: string;
|
||
description: string;
|
||
/** Values the user sets per run. Referenced by `timesFrom` on a repeat. */
|
||
inputs?: AutomationInput[];
|
||
/** Shown as a confirmation before the run. Set it on anything that spends money. */
|
||
confirm?: string;
|
||
steps: AutomationStep[];
|
||
}
|
||
|
||
/** A step as handed to the runner: the firm's tab pattern and fallback URL
|
||
* filled in, so the runner never has to know which firm it is working on.
|
||
*
|
||
* `signedOut`/`signedOutWait` are the pre-guard (runner < 0.20) shape of the
|
||
* login guard, still stamped on so a stale runner keeps working — a 0.20+
|
||
* runner ignores them in favour of the run's `guards`. */
|
||
export type ResolvedStep = AutomationStep & {
|
||
urlPattern?: string;
|
||
openUrl?: string;
|
||
signedOut?: string;
|
||
signedOutWait?: number;
|
||
};
|
||
|
||
/** A condition that can take over the page at any point in a run — a login
|
||
* redirect, a session-expired modal, a maintenance page, a rate-limit
|
||
* interstitial. Guards belong to the firm, not to any step: once the page is
|
||
* in that state, every selector resolves against the wrong document, so the
|
||
* run must deal with the guard before anything else can proceed.
|
||
*
|
||
* How a guard trips:
|
||
* - `urlContains` — the tab's URL carries this substring. Checked on every
|
||
* step, for free: the URL rides along with every locate answer.
|
||
* - `selector` — this element is on the page. A DOM probe costs a round
|
||
* trip, so the runner checks these when a step fails, where a guard is
|
||
* the likeliest explanation. A guard may define either or both (both =
|
||
* tripped when either is present).
|
||
*
|
||
* What happens then:
|
||
* 1. `recoverySteps` run, if there are any. They execute *while the guard
|
||
* condition holds* — on the login form, under the modal — so they carry
|
||
* no guard checks of their own; a wrong selector here is worse than
|
||
* elsewhere. Anything written here lives in this file in plain text,
|
||
* and this file is in the repo.
|
||
* 2. With no recovery steps, the run pauses up to `waitSeconds` for a human
|
||
* to deal with it (sign in, dismiss the thing). No recovery steps and no
|
||
* wait means tripping the guard fails the run.
|
||
* 3. Once the condition has verifiably cleared, `resume` decides where the
|
||
* run picks up: `'continue'` retries the step that tripped and carries
|
||
* on; `'restart'` goes back to the automation's first step — for
|
||
* conditions that reset page state (a re-login that lands on the
|
||
* dashboard, a modal that closes the checkout flow).
|
||
*/
|
||
export interface PageGuard {
|
||
/** Stable id — names the guard in run logs and counts its triggers per run. */
|
||
id: string;
|
||
/** Shown in the run log; falls back to `id`. */
|
||
label?: string;
|
||
/** Trip when the tab's URL contains this substring. */
|
||
urlContains?: string;
|
||
/** Trip when this element is on the page. */
|
||
selector?: string;
|
||
/** Steps that clear the condition. Run guard-free, on the guard page itself. */
|
||
recoverySteps?: AutomationStep[];
|
||
/** Where the run picks up once the condition clears. */
|
||
resume: 'restart' | 'continue';
|
||
/** Seconds to pause for a human when there are no `recoverySteps`. */
|
||
waitSeconds?: number;
|
||
/** Times this guard may trip in one run before the run fails; a guard whose
|
||
* recovery "works" without clearing the cause would otherwise loop.
|
||
* Default 3. */
|
||
maxTriggers?: number;
|
||
}
|
||
|
||
/** A guard as handed to the runner: recovery steps expanded and resolved. */
|
||
export type ResolvedGuard = Omit<PageGuard, 'recoverySteps'> & { recoverySteps: ResolvedStep[] };
|
||
|
||
export interface Firm {
|
||
id: string;
|
||
label: string;
|
||
/** Chrome match pattern for this firm's tab. Steps inherit it unless they set
|
||
* their own, which keeps one firm's automation from acting on another's tab. */
|
||
urlPattern: string;
|
||
/** Concrete page to open when no tab matches `urlPattern`. A match pattern
|
||
* can't be navigated to, so this has to be spelled out separately. */
|
||
url: string;
|
||
/** Conditions that can interrupt a run at any point, and how to get past
|
||
* them. Every firm wants at least the signed-out guard: when a session
|
||
* expires the site redirects to its login page, and a run that lands there
|
||
* must stop rather than click through the form. See PageGuard. */
|
||
guards: PageGuard[];
|
||
automations: Automation[];
|
||
}
|
||
|
||
// The AutoBuyer's list of prop-firm *websites* to click through. Unrelated to
|
||
// the firm catalog in lib/firm-catalog.ts, which is the Tradovate side — the
|
||
// names overlap but nothing joins them. Linking the two is future work.
|
||
export const FIRMS: Firm[] = [
|
||
{
|
||
id: 'tradeify',
|
||
label: 'Tradeify',
|
||
urlPattern: 'https://app-f.tradeify.co/*',
|
||
url: 'https://app-f.tradeify.co/',
|
||
guards: [
|
||
{
|
||
id: 'signed-out',
|
||
label: 'Signed out',
|
||
urlContains: '/auth/',
|
||
resume: 'continue',
|
||
waitSeconds: 300,
|
||
recoverySteps: [
|
||
{ action: 'click', selector: 'form > div > div:last-child button', label: 'Login' },
|
||
],
|
||
},
|
||
],
|
||
automations: [
|
||
{
|
||
id: 'buy-accounts',
|
||
label: 'Buy Accounts x 5',
|
||
description: 'Opens the Add Account flow.',
|
||
confirm: 'This drives the real mouse against Tradeify and can spend money. Continue?',
|
||
inputs: [{ id: 'count', label: 'Accounts - 5 Pack', default: 1, min: 1, max: 3 }],
|
||
steps: [
|
||
// The tab is routinely left on another Tradeify page (/the-circuit,
|
||
// an account view). The Add Account link only exists on the
|
||
// dashboard, so go there first rather than assuming.
|
||
{ action: 'navigate', label: 'Open the Tradeify dashboard' },
|
||
// `a.add_account_btn` is the authored class on the Add Account
|
||
// link — confirmed against a real capture, matchCount 1. The MUI
|
||
// hash classes on the same element (mui-*) are regenerated on
|
||
// every site build, so they are not safe to select on.
|
||
{ action: 'repeat', timesFrom: 'count', steps: [
|
||
{ action: 'click', selector: 'a.add_account_btn', label: 'Open Add Account' },
|
||
{ action: 'waitFor', selector: 'div.account_types:nth-child(3) > div[role="radiogroup"] > div > div:nth-child(2)', timeoutSeconds: 30, label: 'Wait for form' },
|
||
{ action: 'click', selector: 'div.account_types:nth-child(3) > div[role="radiogroup"] > div > div:nth-child(2)'},
|
||
{ action: 'click', selector: '.MuiBox-root:last-child div.account_types:last-child span:last-child'},
|
||
{ action: 'click', selector: 'div.summary_section div.MuiTextField-root input'},
|
||
{ action: 'type', selector: 'div.summary_section div.MuiTextField-root input', text: 'MX8'},
|
||
{ action: 'click', selector: 'div.summary_section div.MuiTextField-root + button'},
|
||
{ action: 'solveCaptcha', timeoutSeconds: 180, label: 'Solve the challenge' },
|
||
{ action: 'click', selector: 'div.summary_section > button.MuiButton-fullWidth', label: 'Buy Account' },
|
||
// TODO: the rest of the purchase flow. Confirm each selector with
|
||
// `clicker.py locate` before adding it here.
|
||
{ action: 'navigate', label: 'Open the Tradeify dashboard' },
|
||
]},
|
||
|
||
],
|
||
},
|
||
{
|
||
id: 'reset',
|
||
label: 'Reset Accounts',
|
||
description: 'Resets Accounts',
|
||
confirm: 'This resets accounts',
|
||
inputs: [{ id: 'count', label: 'Accounts', default: 1, min: 1, max: 10 }],
|
||
steps: [
|
||
{ action: 'navigate', label: 'Open the Tradeify dashboard' },
|
||
|
||
{ action: 'click', selector: 'div.MuiTabs-scroller button:nth-child(2)', label: 'Click Evaluation Tab'},
|
||
{ action: 'click', selector: '.tab-head-right .MuiSwitch-colorPrimary.Mui-checked', skipIfNotFound: true, label: 'Show failed accounts' },
|
||
|
||
// The account list loads a screenful at a time, so a reset
|
||
// count larger than the first page would run out of rows to
|
||
// click. Walk it to the bottom first. Counting the reset
|
||
// buttons rather than the rows means it stops when the thing
|
||
// the loop actually clicks stops appearing.
|
||
//
|
||
// The list scrolls inside dashboard_body, not the document —
|
||
// the automatic guess walks up from a match looking for an
|
||
// overflow ancestor, and setting scrollTop on the wrong
|
||
// element scrolls nothing at all. Named here so it cannot
|
||
// guess wrong; confirmed against a live run, which reported
|
||
// the container as `dashboard_body MuiBox-root mui-0`.
|
||
{
|
||
action: 'scrollToLoad',
|
||
selector: '.status-failed button.reset_btn',
|
||
containerSelector: '.dashboard_body',
|
||
label: 'Load every failed account',
|
||
},
|
||
|
||
{ action: 'repeat', timesFrom: 'count', steps: [
|
||
// one purchase — the steps you already have
|
||
{ action: 'click', selector: '.status-failed button.reset_btn', label: 'Reset Account' },
|
||
{ action: 'solveCaptcha', timeoutSeconds: 180, label: 'Solve the challenge' },
|
||
{ action: 'click', selector: 'button.cancelBtn + button.modalActionBtn' },
|
||
|
||
{ action: 'waitFor', selector: '.reset_acc_modal', absent: true, timeoutSeconds: 30, label: 'Wait for the modal to close' },
|
||
]},
|
||
]
|
||
}
|
||
],
|
||
},
|
||
{
|
||
id: 'lucid',
|
||
label: 'Lucid Trading',
|
||
// One pattern covers both hosts: Chrome's `*.` form matches the apex as
|
||
// well as subdomains, confirmed against a live tab on lucidtrading.com.
|
||
urlPattern: 'https://*.lucidtrading.com/*',
|
||
url: 'https://dash.lucidtrading.com/',
|
||
guards: [
|
||
{
|
||
id: 'signed-out',
|
||
label: 'Signed out',
|
||
// Signed out lands on the apex; signed in stays on dash. The leading `//`
|
||
// anchors this to the start of the host — without it, the substring also
|
||
// matches dash.lucidtrading.com and every step would abort while logged in.
|
||
urlContains: '//lucidtrading.com/',
|
||
resume: 'continue',
|
||
waitSeconds: 300,
|
||
recoverySteps: [
|
||
{ action: 'click', selector: 'button.lucid-login-btn', label: 'Login' },
|
||
],
|
||
},
|
||
],
|
||
automations: [
|
||
{
|
||
id: 'buy-account',
|
||
label: 'Buy Account',
|
||
description: 'Opens the Add Account flow.',
|
||
confirm: 'This drives the real mouse against Lucid and can spend money. Continue?',
|
||
inputs: [{ id: 'count', label: 'Accounts', default: 1, min: 1, max: 15 }],
|
||
steps: [
|
||
// The tab is routinely left on another Tradeify page (/the-circuit,
|
||
// an account view). The Add Account link only exists on the
|
||
// dashboard, so go there first rather than assuming.
|
||
{ action: 'navigate', label: 'Open the Lucid dashboard' },
|
||
{ action: 'click', selector: 'a[data-route="/add-account"]', label: 'Open Add Account' },
|
||
{ action: 'click', selector: 'button.tab-flex', label: 'Open Flex Accounts' },
|
||
|
||
|
||
{ action: 'repeat', timesFrom: 'count', steps: [
|
||
// one purchase — the steps you already have
|
||
{ action: 'click', selector: '.slide:nth-child(2) .drawdown-option:last-child', label: 'Disable DLL' },
|
||
{ action: 'click', selector: '.slide:nth-child(2) .cta-btn', label: 'Open' },
|
||
{ action: 'click', selector: '.platform-btn:first-child', label: 'CQG' },
|
||
|
||
// The checkout renders in a WooCommerce iframe on
|
||
// lucidtrading.com (parent app is dash.lucidtrading.com), so
|
||
// every step here targets the frame. The coupon field id
|
||
// varies between WooCommerce's default (#coupon_code /
|
||
// 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: 'waitFor', selector: 'button#apply_coupon_button', frame: '#woocommerceModal iframe', label: 'Wait for apply coupon button to open' },
|
||
{ 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#apply_coupon_button', frame: '#woocommerceModal iframe', label: 'Apply coupon' },
|
||
// Target the checkbox itself, not the .validate-required
|
||
// row: that class is on every required field (name, email,
|
||
// …), so its first match is usually a text input, and even
|
||
// on the terms row the click lands in the empty right half
|
||
// of a full-width <p> rather than on the box. `:not(:checked)`
|
||
// makes the two clicks self-sequence — the first tick checks
|
||
// box one, so the second click resolves to box two — and
|
||
// re-clicking never un-ticks a box already checked.
|
||
//
|
||
// WooCommerce only stamps validate-required on its own
|
||
// required rows; the second agreement box here is theme
|
||
// markup without that class, so the second click also
|
||
// sweeps the whole checkout form for unchecked boxes —
|
||
// minus the ones that must never be ticked (different
|
||
// shipping address expands extra fields, mailing-list
|
||
// opt-ins). No skipIfNotFound: silently skipping the
|
||
// second agreement just moves the failure to the
|
||
// modal-close gate below with a misleading timeout.
|
||
{ action: 'click', selector: 'div#payment > div > div.woocommerce-terms-and-conditions-wrapper > p > label', frame: '#woocommerceModal iframe', label: 'Agree to terms' },
|
||
{ action: 'click', selector: '.woocommerce-invalid-required-field input', frame: '#woocommerceModal iframe', skipIfNotFound: true, label: 'Agree to terms if it didnt work' },
|
||
|
||
|
||
{ action: 'click', selector: '.woocommerce-terms-and-conditions-wrapper+p input', frame: '#woocommerceModal iframe', label: 'Agree to refund policy' },
|
||
{ action: 'click', selector: 'button#place_order', frame: '#woocommerceModal iframe', label: 'Place order' },
|
||
|
||
{ action: 'waitFor', selector: '#woocommerceModal', absent: true, hidden: true, timeoutSeconds: 30, label: 'Wait for the modal to close' },
|
||
|
||
|
||
{ action: 'waitFor', selector: 'div#successModal', label: 'Wait to click the success modal' },
|
||
{ action: 'click', selector: 'div#successModal button', label: 'Close the success modal' },
|
||
]},
|
||
],
|
||
},
|
||
],
|
||
},
|
||
];
|
||
|
||
/** Runs store one string, so it has to identify the automation globally — and
|
||
* every firm will plausibly have its own "buy-accounts". Hence firm:automation
|
||
* rather than the bare id. */
|
||
export function automationKey(firmId: string, automationId: string): string {
|
||
return `${firmId}:${automationId}`;
|
||
}
|
||
|
||
export function getFirm(id: string): Firm | undefined {
|
||
return FIRMS.find((f) => f.id === id);
|
||
}
|
||
|
||
export function findAutomation(key: string): { firm: Firm; automation: Automation } | undefined {
|
||
for (const firm of FIRMS) {
|
||
for (const automation of firm.automations) {
|
||
if (automationKey(firm.id, automation.id) === key) return { firm, automation };
|
||
}
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
/** Clamp a user-supplied count to what the automation declared. Never trust the
|
||
* number that arrived over the wire: each iteration can be a purchase. */
|
||
export function resolveCount(
|
||
step: Extract<AutomationStep, { action: 'repeat' }>,
|
||
automation: Automation,
|
||
inputs: RunInputs,
|
||
): number {
|
||
if (!step.timesFrom) return Math.max(1, Math.floor(step.times ?? 1));
|
||
|
||
const declared = (automation.inputs ?? []).find((i) => i.id === step.timesFrom);
|
||
const raw = inputs[step.timesFrom];
|
||
if (!declared) return 1;
|
||
|
||
const n = Number.isFinite(raw) ? Math.floor(raw) : declared.default;
|
||
return Math.min(declared.max, Math.max(declared.min, n));
|
||
}
|
||
|
||
/** Give each step of an unrolled iteration a label that says which pass it is,
|
||
* so a failure on the third purchase reads as such in the run log. */
|
||
function labelled(step: ResolvedStep, iteration: number, total: number): ResolvedStep {
|
||
if (total <= 1) return step;
|
||
return { ...step, label: `${step.label ?? describeStep(step)} (${iteration}/${total})` };
|
||
}
|
||
|
||
/** The guard a pre-0.20 runner knew as "the" login guard: the first URL guard.
|
||
* Its pattern is still stamped onto steps (`signedOut`) and its recovery steps
|
||
* still ship as `authSteps`, so a stale runner keeps working until restarted.
|
||
* A 0.20+ runner ignores both in favour of the run-level `guards`. */
|
||
function legacyLoginGuard(firm: Firm): PageGuard | undefined {
|
||
return firm.guards.find((g) => g.urlContains);
|
||
}
|
||
|
||
/** One non-repeat step, with the firm's tab pattern and URLs filled in. */
|
||
function resolveOne(firm: Firm, step: AutomationStep): ResolvedStep {
|
||
const login = legacyLoginGuard(firm);
|
||
if (step.action === 'wait') return step;
|
||
if (step.action === 'navigate') {
|
||
return {
|
||
...step,
|
||
url: step.url ?? firm.url,
|
||
urlPattern: firm.urlPattern,
|
||
openUrl: firm.url,
|
||
signedOut: login?.urlContains,
|
||
signedOutWait: login?.waitSeconds,
|
||
};
|
||
}
|
||
if (step.action === 'waitFor') {
|
||
return { ...step, urlPattern: firm.urlPattern, openUrl: firm.url };
|
||
}
|
||
if (step.action === 'scrollToLoad') {
|
||
// Acts on nothing, so no signed-out guard — same treatment as waitFor.
|
||
return { ...step, urlPattern: firm.urlPattern, openUrl: firm.url };
|
||
}
|
||
if (step.action === 'solveCaptcha') {
|
||
// No guard either, and deliberately: a login form is exactly where a
|
||
// captcha shows up, so auth steps have to be able to solve one.
|
||
return { ...step, urlPattern: firm.urlPattern, openUrl: firm.url };
|
||
}
|
||
if (step.action === 'repeat') {
|
||
// expand() peels these off first; reaching here means a caller bypassed it.
|
||
throw new Error('repeat steps must be expanded, not resolved directly');
|
||
}
|
||
return {
|
||
...step,
|
||
urlPattern: step.urlPattern ?? firm.urlPattern,
|
||
openUrl: firm.url,
|
||
signedOut: login?.urlContains,
|
||
signedOutWait: login?.waitSeconds,
|
||
};
|
||
}
|
||
|
||
/** Flatten repeats into a plain list.
|
||
*
|
||
* Expanding here rather than looping in the runner keeps the runner unchanged,
|
||
* makes the run's total step count honest, and puts every iteration in the run
|
||
* log as its own line. The cost is that the count must be known up front, which
|
||
* it is: either fixed in the config or supplied before the run starts.
|
||
*/
|
||
function expand(
|
||
steps: AutomationStep[],
|
||
firm: Firm,
|
||
automation: Automation,
|
||
inputs: RunInputs,
|
||
depth = 0,
|
||
): ResolvedStep[] {
|
||
if (depth > MAX_REPEAT_DEPTH) {
|
||
throw new Error(`repeat nested more than ${MAX_REPEAT_DEPTH} deep`);
|
||
}
|
||
|
||
const out: ResolvedStep[] = [];
|
||
for (const step of steps) {
|
||
if (step.action === 'repeat') {
|
||
const times = resolveCount(step, automation, inputs);
|
||
for (let i = 1; i <= times; i++) {
|
||
for (const inner of expand(step.steps, firm, automation, inputs, depth + 1)) {
|
||
out.push(labelled(inner, i, times));
|
||
}
|
||
}
|
||
} else {
|
||
out.push(resolveOne(firm, step));
|
||
}
|
||
|
||
if (out.length > MAX_RESOLVED_STEPS) {
|
||
throw new Error(`expands to more than ${MAX_RESOLVED_STEPS} steps`);
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
export function resolveSteps(firm: Firm, automation: Automation, inputs: RunInputs = {}): ResolvedStep[] {
|
||
return expand(automation.steps, firm, automation, inputs);
|
||
}
|
||
|
||
/** Recovery steps run on the guard page itself, so they get the firm's tab
|
||
* pattern but deliberately no `signedOut` stamp — that exists to stop ordinary
|
||
* steps acting on a login form, and these are the exception.
|
||
*
|
||
* They share the expansion path, so a repeat works here too; there are no
|
||
* per-run inputs during recovery, so `timesFrom` falls back to a single pass. */
|
||
function resolveRecoverySteps(firm: Firm, steps: AutomationStep[]): ResolvedStep[] {
|
||
const stub: Automation = { id: '_guard', label: 'guard recovery', description: '', steps };
|
||
return expand(steps, firm, stub, {}).map((step) =>
|
||
step.action === 'wait' ? step : { ...step, signedOut: undefined, signedOutWait: undefined }
|
||
);
|
||
}
|
||
|
||
/** Guards as handed to the runner, recovery steps expanded. */
|
||
export function resolveGuards(firm: Firm): ResolvedGuard[] {
|
||
return firm.guards.map((guard) => ({
|
||
...guard,
|
||
recoverySteps: resolveRecoverySteps(firm, guard.recoverySteps ?? []),
|
||
}));
|
||
}
|
||
|
||
/** The pre-0.20 payload shape: the login guard's recovery steps as `authSteps`.
|
||
* Kept so a stale runner still recovers from a signed-out redirect. */
|
||
export function resolveAuthSteps(firm: Firm): ResolvedStep[] {
|
||
const login = legacyLoginGuard(firm);
|
||
return login ? resolveRecoverySteps(firm, login.recoverySteps ?? []) : [];
|
||
}
|
||
|
||
/** What a step is doing, for the run log and the dashboard. */
|
||
export function describeStep(step: AutomationStep): string {
|
||
if (step.label) return step.label;
|
||
switch (step.action) {
|
||
case 'click': return `click ${step.selector}`;
|
||
case 'type': return `type into ${step.selector}`;
|
||
case 'wait': return `wait ${step.seconds}s`;
|
||
case 'navigate': return `open ${step.url ?? 'the firm page'}`;
|
||
case 'waitFor':
|
||
if (!step.absent) return `wait for ${step.selector}`;
|
||
return step.hidden
|
||
? `wait for ${step.selector} to disappear from view`
|
||
: `wait for ${step.selector} to disappear`;
|
||
case 'scrollToLoad': return `scroll to load all ${step.selector}`;
|
||
case 'solveCaptcha': return `solve the ${step.provider ?? 'page'} captcha with 2captcha`;
|
||
case 'repeat': {
|
||
const inner = step.steps.length;
|
||
const count = step.timesFrom ? `{${step.timesFrom}}` : `${step.times ?? 1}`;
|
||
return `repeat ${count}× (${inner} step${inner === 1 ? '' : 's'})`;
|
||
}
|
||
}
|
||
}
|