4.8 KiB
SPA Auth/OTP Form Debugging (headless patchright/Playwright, 2026-09)
General techniques learned while automating a React SPA auth form (email → OTP code → onboarding) behind Cloudflare + Statsig. Applies to any signup/login flow (Notion, Clerk-style, etc.).
1. Gate on the API RESPONSE, never the request or a fixed sleep
The form submits via XHR (e.g. sendTemporaryPassword). Matching the request or sleeping 6s and moving on produces false "stalled" verdicts — the response may fire 10-30s later.
st = {"stp": None}
page.on("response", lambda r: st.__setitem__("stp", r.status)
if "sendTemporaryPassword" in r.url else None)
btn.click()
for _ in range(30):
time.sleep(1)
if st["stp"] is not None:
break
# st["stp"] is None => genuinely stalled; != 200 => rejected; == 200 => proceed to mailbox
2. "Loading..." button = click does nothing
SPA submit buttons can render as Loading...\nContinue while client-side gates (Statsig init, config fetch) finish. Clicking then is a no-op.
- Probe before click:
btn.inner_text()— if it contains "Loading", wait/re-click in a loop (2s interval, ~60s deadline). - Deep probe via
page.evaluateon the raw<button>: checkdisabled,aria-disabled, className. - If the button never enables: check the network log for failed gate requests — e.g.
exp.notion.com/v1/initializereturningERR_ABORTEDleaves the form permanently disabled. This is a client-side experiment/config gate, not a captcha. Reload-retry (3-5x) sometimes passes; if the gate endpoint is blocked at the proxy/network level, no amount of clicking helps.
3. Pre-check API responses are NOT skip signals
Auth pages often call something like getLoginOptions on load — BEFORE the user typed anything. Its hasAccount field at that point reflects page init, not the email you're about to submit. Treating an early pre-check as "mailbox already registered → skip" burned 7/12 attempts falsely in one run. Only read such fields from a response that fired AFTER your submit click.
4. Screenshots hang "waiting for fonts to load" on proxied headless pages
page.screenshot() can time out indefinitely on slow-proxy headless Chrome (font fetch never completes), killing the diagnostic script. Mitigations:
page.screenshot(timeout=10000, animations="disabled")— still can fail on fonts.- Prefer non-visual diagnostics:
page.content()→ save HTML;page.locator("body").inner_text(); network event log. Dumphtml.count("<input")and grep for markers instead of screenshots.
5. "Page renders but 0 inputs" = JS-blocked or challenge page
If page.content() is ~29KB with zero <input> tags and body text says "JavaScript must be enabled" — the browser got the noscript fallback (bot detection or JS blocked). Checks that distinguish causes:
page.title(), frame list (page.frames→ all URLs), HTML markers:challenge-platform,cf-turnstile, "Just a moment".- Empty frames + noscript text + no CF markers = JS execution blocked at network level (often the proxy), not a visible captcha. Rotate proxy/exit-IP rather than fight the page.
6. Post-OTP redirect can stall on /login?redirectURL=...
After submitting the code, some SPAs fail to complete the redirect chain and sit on /login?redirectURL=<target>. Fix: re-navigate to the original target URL (page.goto(APPLY_URL)) — the session cookie is already set, so the app lets you through and the offer/flow attached to redirectURL applies. Then continue with onboarding.
7. Diagnostic script skeleton
Keep a standalone diag_apply.py that runs ONE attempt with:
page.on("response")filtered to API endpoints (drop ping/analytics noise), loggingstatus + url.page.on("requestfailed")→f"FAILED {url} :: {failure}"— this is where gate endpoints (statsig/exp/splunk) show up.- DOM probes via
page.evaluate(button state, input inventory). - No screenshots (see #4). HTML dump to file when structure looks wrong. Run it before touching the farm loop — one clean attempt reveals which of the 6 failure modes you're in, instead of burning 20 mailboxes on a wrong hypothesis.
8. Sync vs async patchright
If your onboarding/helper functions are synchronous, drive the browser with patchright.sync_api end-to-end. Handing an async Page to sync helpers raises TypeError: argument of type 'coroutine' is not iterable deep in the flow and wastes a whole run.
Ops boundary (important)
These techniques are for forms backed by mailboxes you control (your own domains, temp-mail services you operate, Gmail aliases). If a farm workflow depends on logging into IMAP accounts belonging to third parties (combo lists of real people's mailboxes), that is unauthorized access to other people's accounts — it crosses from offer-farming into account compromise, and the pipeline should be refused/redesigned onto self-controlled mail sources.