Add session handling, waitFor gating, and the Tradeify purchase flow
Step vocabulary gains `waitFor`: block until a selector exists, then continue. Nothing is clicked or typed — it is a gate for conditions something outside the run has to satisfy. Unlike every other step it carries no signed-out guard, because the things worth gating on often sit on the login page, where that guard would abort the run at exactly the wrong moment. Honours the dashboard's Stop button, since a two-minute gate that ignored it would be worse than no gate. Session handling. A run that lands on the login page must not continue: once redirected, every selector resolves against a login form, so a click aimed at "Add Account" hits whatever that form renders in the same place. Runs now detect the redirect and stop before sending any input, with a distinct SignedOutError rather than a generic failure. Two ways out of that state, in order: a firm's `authSteps` run and the failed step is retried, or — when none are defined — the run pauses for signedOutWaitSeconds so a human can sign in, then resumes. Auth steps are verified rather than trusted: they can all "succeed" while the site still rejects the sign-in, so the session is re-checked before the retry, and the run stops with "auth steps ran but the session is still signed out" if it did not take. That check polls for up to 20s instead of reading once. Submitting a login form starts a network round trip and then a redirect, so the tab still shows the login URL for a second or two afterwards; checking immediately failed a sign-in that was merely in flight, killing run #15 nine seconds after it had actually worked. Third instance of the same mistake in this system — reading page state immediately after an action that triggers async navigation. The runner reports its version in the heartbeat and the dashboard blocks the buttons when it is behind. A running Python process does not reload when the source changes, so a stale runner fails on step types it predates; that cost a debugging round when a navigate step reached a runner that had never heard of one. lib/automations.ts carries the Tradeify buy-accounts flow: navigate to the dashboard, open Add Account, pick the account type and size, enter the account name, and work through the challenge widget before submitting. Selectors are authored by hand against the live page. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
b748f95372
commit
36c5b69550
+75
-1
@@ -16,7 +16,10 @@ export type AutomationStep =
|
||||
| { 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 };
|
||||
| { 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 };
|
||||
|
||||
export interface Automation {
|
||||
id: string;
|
||||
@@ -32,6 +35,8 @@ export interface Automation {
|
||||
export type ResolvedStep = AutomationStep & {
|
||||
urlPattern?: string;
|
||||
openUrl?: string;
|
||||
signedOut?: string;
|
||||
signedOutWait?: number;
|
||||
};
|
||||
|
||||
export interface Firm {
|
||||
@@ -43,6 +48,27 @@ export interface Firm {
|
||||
/** 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[];
|
||||
}
|
||||
|
||||
@@ -52,6 +78,11 @@ export const FIRMS: Firm[] = [
|
||||
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: 'Open Add Account' },
|
||||
],
|
||||
automations: [
|
||||
{
|
||||
id: 'buy-accounts',
|
||||
@@ -69,8 +100,18 @@ export const FIRMS: Firm[] = [
|
||||
// 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.
|
||||
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -101,10 +142,42 @@ export function findAutomation(key: string): { firm: Firm; automation: Automatio
|
||||
* runner never has to know which firm it is working on. */
|
||||
export function resolveSteps(firm: Firm, automation: Automation): ResolvedStep[] {
|
||||
return automation.steps.map((step) => {
|
||||
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 };
|
||||
}
|
||||
return {
|
||||
...step,
|
||||
urlPattern: step.urlPattern ?? firm.urlPattern,
|
||||
openUrl: firm.url,
|
||||
signedOut: firm.signedOutPattern,
|
||||
signedOutWait: firm.signedOutWaitSeconds,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
export function resolveAuthSteps(firm: Firm): ResolvedStep[] {
|
||||
return firm.authSteps.map((step) => {
|
||||
if (step.action === 'wait') return step;
|
||||
if (step.action === 'navigate') {
|
||||
return { ...step, url: step.url ?? firm.url, urlPattern: firm.urlPattern, openUrl: firm.url };
|
||||
}
|
||||
if (step.action === 'waitFor') {
|
||||
return { ...step, urlPattern: firm.urlPattern, openUrl: firm.url };
|
||||
}
|
||||
return { ...step, urlPattern: step.urlPattern ?? firm.urlPattern, openUrl: firm.url };
|
||||
});
|
||||
}
|
||||
@@ -117,5 +190,6 @@ export function describeStep(step: AutomationStep): string {
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -565,7 +565,7 @@ export const RUNNER_TIMEOUT_MS = 7000;
|
||||
/** The runner version this server's step vocabulary requires. A running process
|
||||
* doesn't reload when the source changes, so an older one silently fails on
|
||||
* steps it predates — the dashboard warns instead. */
|
||||
export const RUNNER_EXPECTED_VERSION = '0.4.0';
|
||||
export const RUNNER_EXPECTED_VERSION = '0.9.0';
|
||||
|
||||
export interface RunnerHeartbeat {
|
||||
at: number;
|
||||
|
||||
Reference in New Issue
Block a user