Let the extension update itself, and host the dashboard

Chrome has no update channel for an unpacked extension, so every pull left one
manual step: click reload on its card. The dashboard now publishes the version
its own checkout ships at /api/autobuyer/status, and the extension reloads
itself when it is behind — chrome.runtime.reload() re-reads the folder from
disk, which the update check has already pulled into. It only ever runs code
that is on disk, so a reload that changes nothing is reported and waited out
rather than repeated; a dashboard that is behind is ignored.

Also adds the deploy stack for running the dashboard on the droplet, next to
the autotrader one: it joins that nginx's network and renders a server block
into its conf.d rather than standing up a second ingress. AUTOFIRMER_DB moves
the SQLite file onto a volume, since the working directory in a container is
replaced on every deploy.

And waitFor gained `hidden`: an absent gate matched on DOM presence only, so a
modal that closes by going display:none could never satisfy it. The extension
grew a visibility probe for this — locate cannot answer it, because absent and
hidden are both errors there and a wait gate has to tell them apart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-08-31 12:30:59 -05:00
co-authored by Claude Opus 5
parent 1f4459985a
commit d1e8668440
18 changed files with 637 additions and 28 deletions
+27 -9
View File
@@ -23,10 +23,16 @@ export type AutomationStep =
| { 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` means removed, not merely
// hidden: an element still in the DOM with display:none keeps matching, so
// for those use a selector that only matches while it is visible.
| { action: 'waitFor'; selector: string; index?: number; absent?: boolean; timeoutSeconds?: number; label?: string }
// 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; 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
@@ -171,7 +177,7 @@ export const FIRMS: Firm[] = [
{ 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:last-child', label: 'Open Add Account' },
{ action: 'click', selector: 'div.summary_section > button:last-child', label: 'Buy Account' },
// TODO: the rest of the purchase flow. Confirm each selector with
// `clicker.py locate` before adding it here.
@@ -249,9 +255,20 @@ export const FIRMS: Firm[] = [
// 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: 'a[data-route="/add-account"]', label: 'Open Add Account' },
{ action: 'click', selector: 'input.coupon_code_field', label: 'Click on coupon code'},
{ action: 'type', selector: 'input.coupon_code_field', text: 'THEPROPFARM'},
{ action: 'click', selector: '.apply_coupon_button'},
{ action: 'click', selector: '.validate-required:not(.woocommerce-validated)', label: 'Agree to terms' },
{ action: 'click', selector: '.validate-required:not(.woocommerce-validated)', label: 'Agree to terms 2' },
{ action: 'waitFor', selector: '#woocommerceModal', absent: true, hidden: true, timeoutSeconds: 30, label: 'Wait for the modal to close' },
]},
],
},
@@ -405,9 +422,10 @@ export function describeStep(step: AutomationStep): string {
case 'wait': return `wait ${step.seconds}s`;
case 'navigate': return `open ${step.url ?? 'the firm page'}`;
case 'waitFor':
return step.absent
? `wait for ${step.selector} to disappear`
: `wait for ${step.selector}`;
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': {