// 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. | { action: 'click'; selector: string; index?: number; skipIfNotFound?: boolean; urlPattern?: string; label?: string } | { action: 'type'; selector: string; text: string; index?: number; clear?: boolean; skipIfNotFound?: boolean; urlPattern?: 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, then carry on. Nothing is clicked or typed — // this is a gate, for conditions something outside the run has to satisfy. | { action: 'waitFor'; selector: string; index?: number; 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 }; /** 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; /** 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. */ export type ResolvedStep = AutomationStep & { urlPattern?: string; openUrl?: string; signedOut?: string; signedOutWait?: number; }; 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; /** Substring identifying the signed-out page. When a session expires the site * redirects here, and every subsequent selector resolves against a login form * — so a run that lands on it must stop rather than click through it. */ signedOutPattern: string; /** Seconds to pause and let a human sign in when a run hits the login page. * 0 aborts instead. Used only when `authSteps` is empty. */ signedOutWaitSeconds: number; /** Steps run when a run lands on the login page, before retrying the step * that hit it. Left empty here on purpose — fill it in yourself. * * Two things to know if you do: * - These run *while on the signed-out page*, so unlike normal steps they * carry no signed-out guard. Nothing stops them clicking around a login * form; that is the point, and also why 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. * * While empty, a run that hits the login page falls back to pausing for * `signedOutWaitSeconds` so you can sign in by hand. */ authSteps: AutomationStep[]; automations: Automation[]; } export const FIRMS: Firm[] = [ { id: 'tradeify', label: 'Tradeify', urlPattern: 'https://app-f.tradeify.co/*', url: 'https://app-f.tradeify.co/', signedOutPattern: '/auth/', signedOutWaitSeconds: 300, authSteps: [ { 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: 'click', selector: 'a.add_account_btn', label: 'Open Add Account' }, { action: 'wait', seconds: 2, label: 'Wait for the form' }, { action: 'click', selector: 'div.account_types:nth-child(3) > div[role="radiogroup"] > div > div:nth-child(2)'}, { action: 'click', selector: 'div.account_types:nth-child(7) 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: 'click', selector: 'div.captcha-solver[data-state="ready"]'}, { action: 'waitFor', selector: "div.captcha-solver[data-state='solved']", timeoutSeconds: 120, label: 'Wait for the challenge' }, { action: 'click', selector: 'div.summary_section > button:last-child', label: 'Open Add Account' }, // TODO: the rest of the purchase flow. Confirm each selector with // `clicker.py locate` before adding it here. ], }, { 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' }, { action: 'repeat', timesFrom: 'count', steps: [ // one purchase — the steps you already have { action: 'click', selector: 'button.reset_btn', label: 'Reset Account' }, { action: 'waitFor', selector: "div.captcha-solver[data-state='ready']", timeoutSeconds: 30, label: 'Wait for the solver' }, { action: 'click', selector: 'div.captcha-solver[data-state="ready"]'}, { action: 'waitFor', selector: "div.captcha-solver[data-state='solved']", timeoutSeconds: 120, label: 'Wait for the challenge' }, { action: 'click', selector: 'button.cancelBtn + button.modalActionBtn' } ]}, ] } ], }, { 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/', // 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. signedOutPattern: '//lucidtrading.com/', signedOutWaitSeconds: 300, authSteps: [ { 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: 'repeat', timesFrom: 'count', steps: [ // one purchase — the steps you already have { action: 'click', selector: 'a[data-route="/add-account"]', label: 'Open Add Account' }, ]}, ], }, ], }, ]; /** 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, 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})` }; } /** One non-repeat step, with the firm's tab pattern and URLs filled in. */ function resolveOne(firm: Firm, step: AutomationStep): ResolvedStep { if (step.action === 'wait') return step; if (step.action === 'navigate') { return { ...step, url: step.url ?? firm.url, urlPattern: firm.urlPattern, openUrl: firm.url, signedOut: firm.signedOutPattern, signedOutWait: firm.signedOutWaitSeconds, }; } if (step.action === 'waitFor') { 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: firm.signedOutPattern, signedOutWait: firm.signedOutWaitSeconds, }; } /** 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); } /** Auth steps run on the login page itself, so they get the firm's tab pattern * but deliberately no `signedOut` guard — that guard 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 auth, so `timesFrom` falls back to a single pass. */ export function resolveAuthSteps(firm: Firm): ResolvedStep[] { const stub: Automation = { id: '_auth', label: 'auth', description: '', steps: firm.authSteps }; return expand(firm.authSteps, firm, stub, {}).map((step) => step.action === 'wait' ? step : { ...step, signedOut: undefined, signedOutWait: undefined } ); } /** 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': return `wait for ${step.selector}`; case 'repeat': { const inner = step.steps.length; const count = step.timesFrom ? `{${step.timesFrom}}` : `${step.times ?? 1}`; return `repeat ${count}× (${inner} step${inner === 1 ? '' : 's'})`; } } }