Files
autofirmer-expanded/lib/automations.ts
T
Brandon LiandClaude Fable 5 b1dccd7dec Revamp login guards into general per-firm page guards
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>
2026-09-14 12:50:14 -05:00

567 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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'})`;
}
}
}