From cd298b0667642a4121519fc073e524ad09611c95 Mon Sep 17 00:00:00 2001 From: Timothy Oluwatobi Ojebiyi Date: Tue, 20 Oct 2026 15:31:29 +0100 Subject: [PATCH] feat: type the code a job site emails into Windrush, and it sends the form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greenhouse asked Catapult's send for an emailed code "to confirm you're a human", and the run could only give up. Now a live run holds the filled form open for the user's code: the run shows "Needs your code" with the site's message and a box; the code typed there is entered on the form and the application sent. The code always comes from the user. The check is there to confirm a person is applying, so Windrush never fetches a code itself, not even from a connected Gmail inbox; the only way in is the user typing it (POST .../code). - browser_agent: when the submit stops at a code page and ask_code is given, it waits for the user's code, types it into the page's code boxes (one character a box, as Greenhouse has eight, or one field labelled as a code; insertText, so React boxes take it), checks the boxes hold it, and sends again. A code that doesn't take is asked for again, up to three. - autopilot: the run is "awaiting_code" while it waits, with the site's line and how long it waits (10 minutes); give_code hands a typed code to the waiting form. Sent: "Sent after you typed in the code…". No code in time, or none that took: held as before, saying which, with the handover pack. Practice runs never wait. - A restart while a form waits holds it (the browser is gone). - POST /autopilot/runs/{id}/code: letters and digits, 4 to 12; 409 once the form isn't waiting; another user's run is out of reach. - Frontend: "Needs your code" in the queue and the sidebar count, with a code box on the run. Tests: fast (a wrong code then the right one sends it; no code in time holds it; practice never waits; restart; the endpoint), real browser (the code entered in eight boxes and in one field, then sent; no code leaves it unsent) and the e2e smoke test (the code typed goes to its run). --- api/app/autopilot.py | 44 +++++++++- api/app/browser_agent.py | 107 ++++++++++++++++++++++-- api/app/main.py | 13 +++ api/app/tracker.py | 11 ++- api/tests/browser/browser_support.py | 9 +- api/tests/browser/site/code_boxes.html | 22 +++++ api/tests/browser/site/code_field.html | 12 +++ api/tests/browser/test_browser_agent.py | 48 +++++++++++ api/tests/test_autopilot.py | 89 ++++++++++++++++++-- frontend/e2e/smoke.spec.ts | 18 +++- frontend/src/app/autopilot/page.tsx | 45 +++++++++- frontend/src/components/Sidebar.tsx | 3 +- frontend/src/lib/autopilot.ts | 6 ++ frontend/src/lib/types.ts | 3 +- 14 files changed, 404 insertions(+), 26 deletions(-) create mode 100644 api/tests/browser/site/code_boxes.html create mode 100644 api/tests/browser/site/code_field.html diff --git a/api/app/autopilot.py b/api/app/autopilot.py index f9cac8a..e1b685d 100644 --- a/api/app/autopilot.py +++ b/api/app/autopilot.py @@ -226,6 +226,17 @@ class _Run: _runs: dict[str, _Run] = {} # user id → the run in progress _last: dict[str, dict] = {} # user id → how the last run ended +_codes: dict[str, asyncio.Future] = {} # run id → the code the user types in, while its form waits for one +_CODE_WAIT_S = 10 * 60 # how long a form held open for an emailed code waits for the user's + + +def give_code(run_id: str, code: str) -> bool: + """Hand the code the user typed into Windrush to the form waiting for it; False when none is waiting.""" + waiting = _codes.get(run_id) + if waiting is None or waiting.done(): + return False + waiting.set_result(code) + return True class _Stop(Exception): @@ -368,6 +379,7 @@ async def _apply(state: _Run, user_id: str, job: dict, verdict: dict, cfg: dict, run = tracker.get_autopilot_run(user_id, run_id) state.job = f"{job.get('title', '')} at {job.get('company', '')}" held: list[str] = [] + given: list[str] = [] # codes the user typed in for this form final: dict = {} questions: list[dict] = [] try: @@ -395,12 +407,32 @@ async def decide(filled: dict) -> bool: held.append(f"Your limit of {cfg['daily_cap']} a day was reached before this one could go.") return live and not held + async def ask_code(asked: str, attempt: int) -> str | None: + # The site emailed the user a code to confirm a person is applying: the form waits, open, for them to + # type it into Windrush (give_code). It never comes from anywhere else (see browser_agent.CodeAsker). + again = " The last code didn't work: check it's the newest one." if attempt > 1 else "" + tracker.update_autopilot_run(run_id, user_id=user_id, outcome="awaiting_code", reasons=[ + f"The job site emailed you a code: “{asked}”{again} Type it in here within {_CODE_WAIT_S // 60} " + f"minutes and Windrush will enter it and send the application."[:600]]) + waiting = asyncio.get_running_loop().create_future() + _codes[run_id] = waiting + try: + code = await asyncio.wait_for(waiting, _CODE_WAIT_S) + except asyncio.TimeoutError: + return None + finally: + _codes.pop(run_id, None) + given.append(code) + tracker.update_autopilot_run(run_id, user_id=user_id, outcome="running", reasons=[]) + return code + state.step = "Filling in the form" core = persona.get("core_info") or {} async with aclosing(browser_agent.apply_with_browser( job["url"], persona, text, asyncio.Queue(), None, job_email=core.get("job_email", ""), job_password=tracker.get_job_password(user_id), cv_path=_pdf(cv_doc_id), cover_letter_path=_pdf(letter_doc_id), answerer=answerer, decide=decide, + ask_code=ask_code if live else None, )) as steps: async for step in steps: if step.get("done"): @@ -425,10 +457,12 @@ async def decide(filled: dict) -> bool: outcome, reasons = "submitted", [] elif final.get("code_asked"): # The site emailed the candidate a code to prove a person is applying (Catapult, on Greenhouse: "enter the - # 8-character code to confirm you're a human"). That check is theirs to pass: nothing went, so it waits. - outcome, reasons = "needs_you", [f"The job site emailed you a code to confirm a person is applying, so this " - f"wasn't sent. It says: “{final['code_asked']}” Apply on the job's page " - f"with this run's CV and cover letter."[:600]] + # 8-character code to confirm you're a human"), and no code they gave got it through: nothing went. + why = ("the code you typed in didn't take" if given else + f"no code was typed in within {_CODE_WAIT_S // 60} minutes") + outcome, reasons = "needs_you", [f"The job site emailed you a code to confirm a person is applying, and " + f"{why}, so this wasn't sent. It says: “{final['code_asked']}” Apply on the " + f"job's page with this run's handover pack."[:600]] elif final.get("submit_attempted"): detail = final.get("submit_detail") or "no confirmation shown" outcome, reasons = "unconfirmed", [f"The submit button was clicked, but the page didn't confirm it ({detail}). " @@ -457,6 +491,8 @@ async def decide(filled: dict) -> bool: tracker.set_application_questions(app_id, questions, user_id=user_id) reused = (f"Used the CV and cover letter from the {'practice ' if earlier['practice'] else ''}run of " f"{earlier['updated_at'][:10]}. " if earlier else "") + if given and outcome == "submitted": + reused += "Sent after you typed in the code the job site emailed you. " tracker.update_autopilot_run(run_id, user_id=user_id, outcome=outcome, reasons=reasons, questions=questions, notes=(reused + str(final.get("notes") or ""))[:2000], application_id=app_id) logger.info("autopilot: %s -> %s (%d reasons)", state.job, outcome, len(reasons)) diff --git a/api/app/browser_agent.py b/api/app/browser_agent.py index df4ed9c..0542fc9 100644 --- a/api/app/browser_agent.py +++ b/api/app/browser_agent.py @@ -1352,6 +1352,77 @@ async def _code_asked(browser_session) -> str: return "" +# Where the emailed code goes: one box per character (Greenhouse: eight boxes of one character), else one field whose +# label, name or placeholder says code. Marks them data-windrush-code=0..n-1 and returns how many it found. +_CODE_BOXES_JS = r"""() => { + const docs = [document]; + for (const frame of document.querySelectorAll('iframe')) { + try { if (frame.contentDocument) docs.push(frame.contentDocument); } catch (e) {} + } + const skip = ['hidden', 'checkbox', 'radio', 'file', 'submit', 'button', 'email', 'password']; + const inputs = []; + for (const doc of docs) { + for (const el of doc.querySelectorAll('input')) { + const style = doc.defaultView.getComputedStyle(el); + const box = el.getBoundingClientRect(); + if (!skip.includes(el.type) && !el.disabled && style.display !== 'none' && style.visibility !== 'hidden' + && (box.width > 0 || box.height > 0)) inputs.push(el); + } + } + let boxes = inputs.filter((el) => el.maxLength === 1); + if (boxes.length < 4) { + const says = (el) => /code/i.test([el.name, el.id, el.placeholder, el.getAttribute('aria-label') || '', + el.getAttribute('autocomplete') || '', ...[...(el.labels || [])].map((l) => l.textContent)].join(' ')); + boxes = inputs.filter(says).slice(0, 1); + } + boxes.forEach((el, i) => el.setAttribute('data-windrush-code', String(i))); + return JSON.stringify(boxes.length); +}""" + +# Focus marked box __INDEX__ and select what it holds, so the next insertText replaces it. +_FOCUS_CODE_BOX_JS = r"""() => { + const docs = [document]; + for (const frame of document.querySelectorAll('iframe')) { + try { if (frame.contentDocument) docs.push(frame.contentDocument); } catch (e) {} + } + for (const doc of docs) { + const el = doc.querySelector('[data-windrush-code="__INDEX__"]'); + if (el) { el.focus(); if (el.select) el.select(); return JSON.stringify(true); } + } + return JSON.stringify(false); +}""" + +# What the marked boxes hold, in order. +_CODE_TYPED_JS = r"""() => { + const docs = [document]; + for (const frame of document.querySelectorAll('iframe')) { + try { if (frame.contentDocument) docs.push(frame.contentDocument); } catch (e) {} + } + const boxes = docs.flatMap((doc) => [...doc.querySelectorAll('[data-windrush-code]')]); + boxes.sort((a, b) => Number(a.dataset.windrushCode) - Number(b.dataset.windrushCode)); + return JSON.stringify(boxes.map((el) => el.value).join('')); +}""" + + +async def _enter_code(browser_session, code: str) -> bool: + """Type the code the user gave into the page's code box(es), one character a box when there's a box each + (insertText, so React-controlled boxes take it like typing). True when the boxes then hold the code.""" + try: + page, cdp_session = await _focused_page(browser_session) + count = json.loads(await page.evaluate(_CODE_BOXES_JS)) + if not count: + return False + parts = list(code) if count > 1 else [code] + for i, part in enumerate(parts[:count]): + if json.loads(await page.evaluate(_FOCUS_CODE_BOX_JS.replace("__INDEX__", str(i)))) is not True: + return False + await _insert_text(browser_session, cdp_session, part) + return str(json.loads(await page.evaluate(_CODE_TYPED_JS))).upper() == code.upper() + except Exception as exc: + logger.warning("couldn't enter the code: %s", exc) + return False + + async def _submit_button_shown(browser_session) -> bool: """Whether the focused page shows the button that sends the application; False if it can't be read.""" try: @@ -1513,15 +1584,23 @@ def _with_empty_fields(notes: str, empty: list[str], left_blank: list[str] = (), # {ready, error, notes, questions, empty} — and says whether to send it. Nobody reviews it in between. Decider = Callable[[dict], Awaitable[bool]] +# When the send stops at a page wanting a code the site emailed the candidate, `ask_code` gets that page's line and +# the attempt (1, 2, …) and returns the code the user typed into Windrush, or None when none came. The code always +# comes from the user: the check is there to confirm a person is applying, so Windrush never fetches it itself, +# not even from a connected Gmail inbox. +CodeAsker = Callable[[str, int], Awaitable[str | None]] +_MAX_CODE_TRIES = 3 + async def _finish_unattended(decide: Decider, filled: dict, browser_session, llm, sensitive_data: dict | None, - secret: str) -> AsyncGenerator[dict, None]: + secret: str, ask_code: CodeAsker | None = None) -> AsyncGenerator[dict, None]: """ Send the filled form if `decide` says to, else leave it. The last event is done=True and carries the fill's results plus `form_screenshot` (the filled form, before anything is sent); when the submit agent ran, also `submit_attempted`, `submitted` (only if the page confirmed it), `submit_detail`, `code_asked` (the - page's request for a code the site emailed the candidate, when it made one instead of confirming) and - `confirm_screenshot` (the page after the click). + page's request for a code the site emailed the candidate, when it still wants one) and `confirm_screenshot` + (the page after the click). With `ask_code`, a page that wants such a code waits for the user's: up to + _MAX_CODE_TRIES codes are entered and the form sent again. """ final = {"action": "Filled in; not sent", "screenshot": None, "blocked": False, "reason": None, "done": True, "submitted": False, "submit_attempted": False, **filled, @@ -1536,6 +1615,21 @@ async def _finish_unattended(decide: Decider, filled: dict, browser_session, llm "done": False} ok, detail = await _submit_with_agent(llm, browser_session, sensitive_data, secret) code = "" if ok else _redact(await _code_asked(browser_session), secret) + tries = 0 + while code and ask_code is not None and tries < _MAX_CODE_TRIES: + tries += 1 + yield {"action": "Waiting for the code the site emailed you", "screenshot": None, "blocked": False, + "reason": None, "done": False} + given = await ask_code(code, tries) + if not given: + break + yield {"action": "Entering your code and sending the application…", "screenshot": None, "blocked": False, + "reason": None, "done": False} + if not await _enter_code(browser_session, given): + detail = "Couldn't find where the code goes on the page." + break + ok, detail = await _submit_with_agent(llm, browser_session, sensitive_data, secret) + code = "" if ok else _redact(await _code_asked(browser_session), secret) final.update(submit_attempted=True, submitted=ok, submit_detail=detail, code_asked=code, confirm_screenshot=await _screenshot_b64(browser_session), action="Application submitted" if ok else @@ -1555,6 +1649,7 @@ async def apply_with_browser( cover_letter_path: str = "", answerer: Answerer | None = None, decide: Decider | None = None, + ask_code: CodeAsker | None = None, ) -> AsyncGenerator[dict, None]: """ Async generator: the browser-use agent fills the application, then the user reviews it and @@ -1562,7 +1657,8 @@ async def apply_with_browser( under the candidate's name ("Ada_Lovelace_CV.pdf"), not their stored ids. `answerer` answers the form's questions (answers.answer_questions); without one, every question is left for the user. With `decide` the run is unattended: there is no review, and `decide` says whether the filled form is - sent (see _finish_unattended for what the last event then carries). + sent (see _finish_unattended for what the last event then carries); `ask_code` gets the user's code when + the site emails one before it takes the application (see CodeAsker). Yields dicts: { action, screenshot (base64|None), blocked (bool), reason (str|None), done (bool), interactive (bool, optional), submitted (bool, only on the final done event), @@ -1694,7 +1790,8 @@ async def on_step(browser_state, agent_output, step_number): checked = _with_empty_fields("" if ready else summary, empty, left_blank, self_answered) filled = {"ready": ready, "error": agent_error, "notes": checked[:_MAX_NOTES_CHARS], "questions": questions, "empty": empty, "self_answered": self_answered} - async for step in _finish_unattended(decide, filled, browser_session, llm, sensitive_data, job_password): + async for step in _finish_unattended(decide, filled, browser_session, llm, sensitive_data, job_password, + ask_code): yield step return diff --git a/api/app/main.py b/api/app/main.py index 40d7080..2123957 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -1052,6 +1052,19 @@ async def autopilot_dismiss(run_id: str, current_user: Annotated[auth.User, Depe return {"dismissed": run_id} +@app.post("/autopilot/runs/{run_id}/code", status_code=202) +async def autopilot_code(run_id: str, body: dict, current_user: Annotated[auth.User, Depends(auth.get_current_user)]): + """The code a job site emailed you, typed in for the form autopilot holds open for it: it's entered on the form + and the application sent. The only way a code reaches a form: Windrush never fetches one itself.""" + run = _own_run(current_user.id, run_id) + code = re.sub(r"[\s-]+", "", str(body.get("code") or "")) + if not re.fullmatch(r"[A-Za-z0-9]{4,12}", code): + raise HTTPException(status_code=400, detail="That doesn't look like the code: letters and digits only.") + if run["outcome"] != "awaiting_code" or not autopilot.give_code(run_id, code): + raise HTTPException(status_code=409, detail="That form isn't waiting for a code any more.") + return {"accepted": True} + + @app.get("/autopilot/runs/{run_id}/pack") async def autopilot_pack(run_id: str, current_user: Annotated[auth.User, Depends(auth.get_current_user)]): """A run's handover pack, for applying yourself: a ZIP of its CV and cover letter under the names autopilot diff --git a/api/app/tracker.py b/api/app/tracker.py index c5e0865..fecf08b 100644 --- a/api/app/tracker.py +++ b/api/app/tracker.py @@ -129,10 +129,12 @@ # unconfirmed - the submit button was clicked but the page didn't confirm it: the user checks # ready - practice mode: everything checked out, and it would have been sent # needs_you - filled in but held back (see reasons) +# awaiting_code - the form is open on a page wanting a code the site emailed the user; it waits for them to type it in # skipped - not tried: the fit was too low, say # failed - something broke before the form was filled # dismissed - the user cleared it from the queue -AUTOPILOT_OUTCOMES = ("running", "submitted", "unconfirmed", "ready", "needs_you", "skipped", "failed", "dismissed") +AUTOPILOT_OUTCOMES = ("running", "submitted", "unconfirmed", "ready", "needs_you", "awaiting_code", "skipped", "failed", + "dismissed") def init_db(db_path: str = "") -> None: @@ -786,11 +788,16 @@ def autopilot_sent_since(user_id: str, since: str) -> int: def finish_stale_autopilot_runs() -> None: - """Runs left 'running' by a restart are over: nothing is working on them now.""" + """Runs left 'running' or 'awaiting_code' by a restart are over: nothing is working on them now, and a form + waiting for a code closed with the browser.""" con = sqlite3.connect(_DB_PATH) with con: con.execute("UPDATE autopilot_runs SET outcome='failed', reasons=?, updated_at=? WHERE outcome='running'", (json.dumps(["Windrush restarted while this was in progress."]), _now())) + con.execute("UPDATE autopilot_runs SET outcome='needs_you', reasons=?, updated_at=? " + "WHERE outcome='awaiting_code'", + (json.dumps(["Windrush restarted while the form waited for the code the job site emailed you, " + "so it wasn't sent. Apply on the job's page with this run's handover pack."]), _now())) con.close() diff --git a/api/tests/browser/browser_support.py b/api/tests/browser/browser_support.py index 7f5349d..d73a5bb 100644 --- a/api/tests/browser/browser_support.py +++ b/api/tests/browser/browser_support.py @@ -76,8 +76,10 @@ class ScriptedLLM: name = property(lambda self: "scripted") model_name = property(lambda self: "scripted") - def __init__(self, fill_success: bool = True, submit_success: bool = True): - self.fill_success, self.submit_success = fill_success, submit_success + def __init__(self, fill_success: bool = True, submit_success: bool | list[bool] = True): + # submit_success as a list: one result per submit agent, in order (the last repeats). + self.fill_success = fill_success + self.submit_results = list(submit_success) if isinstance(submit_success, list) else [submit_success] self.tasks: list[str] = [] self.seen: list[str] = [] @@ -88,7 +90,8 @@ def _text(messages) -> str: def next_action(self, text: str) -> list[dict]: is_submit = "candidate has reviewed" in text self.tasks.append("submit" if is_submit else "fill") - ok = self.submit_success if is_submit else self.fill_success + ok = (self.submit_results.pop(0) if len(self.submit_results) > 1 else self.submit_results[0]) if is_submit \ + else self.fill_success return [{"done": {"text": "submit agent ran" if is_submit else "Filled every field", "success": ok}}] async def ainvoke(self, messages, output_format=None, **kwargs): diff --git a/api/tests/browser/site/code_boxes.html b/api/tests/browser/site/code_boxes.html new file mode 100644 index 0000000..da90f1c --- /dev/null +++ b/api/tests/browser/site/code_boxes.html @@ -0,0 +1,22 @@ + +Test Application Form, wanting the emailed code + + +
+

A verification code was sent to ada@example.com. To submit your application, enter the 8-character code to confirm you're a human.

+ +
+ +
+ + diff --git a/api/tests/browser/site/code_field.html b/api/tests/browser/site/code_field.html new file mode 100644 index 0000000..5683c06 --- /dev/null +++ b/api/tests/browser/site/code_field.html @@ -0,0 +1,12 @@ + +Test Application Form, wanting the emailed code in one field + +
+ + +

We emailed you a one-time code. Enter it below to finish your application.

+ + + +
+ diff --git a/api/tests/browser/test_browser_agent.py b/api/tests/browser/test_browser_agent.py index 92c577c..7136e99 100644 --- a/api/tests/browser/test_browser_agent.py +++ b/api/tests/browser/test_browser_agent.py @@ -513,6 +513,54 @@ async def flow(): assert_no_chromium_left() +@pytest.mark.parametrize("page, asked", [ + ("code_boxes.html", "A verification code was sent to ada@example.com. To submit your application, enter the " + "8-character code to confirm you're a human."), + ("code_field.html", "We emailed you a one-time code. Enter it below to finish your application."), +]) +def test_the_code_the_user_types_in_is_entered_and_the_form_sent_again(ba, site, monkeypatch, page, asked): + llm = ScriptedLLM(submit_success=[False, True]) # the first send stops at the code; the second goes + monkeypatch.setattr(ba, "_make_browser_llm", lambda: llm) + typed: list[str] = [] + real_enter = ba._enter_code + + async def enter_and_read(session, code): + ok = await real_enter(session, code) + held, _ = await ba._focused_page(session) + typed.append(json.loads(await held.evaluate(ba._CODE_TYPED_JS))) + return ok + monkeypatch.setattr(ba, "_enter_code", enter_and_read) + calls: list[tuple[str, int]] = [] + + async def ask_code(line, attempt): + calls.append((line, attempt)) + return "AB12CD34" + + async def flow(): + return [event async for event in ba.apply_with_browser( + f"{site}/{page}", {"core_info": {"first_name": "Ada"}}, "", asyncio.Queue(), + decide=lambda filled: asyncio.sleep(0, result=True), ask_code=ask_code)] + events = asyncio.run(flow()) + final = events[-1] + assert calls == [(asked, 1)] and typed == ["AB12CD34"] + assert llm.tasks == ["fill", "submit", "submit"] and final["submitted"] and final["code_asked"] == "" + assert "Waiting for the code the site emailed you" in [e["action"] for e in events] + assert_no_chromium_left() + + +def test_without_a_code_the_form_is_left_unsent(ba, site, monkeypatch): + llm = ScriptedLLM(submit_success=False) + monkeypatch.setattr(ba, "_make_browser_llm", lambda: llm) + + async def flow(): + return [event async for event in ba.apply_with_browser( + f"{site}/code_boxes.html", {"core_info": {"first_name": "Ada"}}, "", asyncio.Queue(), + decide=lambda filled: asyncio.sleep(0, result=True), ask_code=lambda line, attempt: asyncio.sleep(0))] + final = asyncio.run(flow())[-1] + assert llm.tasks == ["fill", "submit"] and not final["submitted"] and final["code_asked"].startswith("A verification") + assert_no_chromium_left() + + class GreenhouseLLM(ScriptedLLM): """Fills greenhouse.html the way the task asks: applicant details with fill_fields, questions through answer_questions with their indexes, then tries to fill a question that was left for the candidate.""" diff --git a/api/tests/test_autopilot.py b/api/tests/test_autopilot.py index 0eed38f..92210f5 100644 --- a/api/tests/test_autopilot.py +++ b/api/tests/test_autopilot.py @@ -142,6 +142,7 @@ def __init__(self): self.decided: dict[str, bool] = {} self.cv_paths: list[str] = [] self.written: list[str] = [] # the documents written, as "cv" / "letter" + self.asks_for_codes: dict[str, bool] = {} # company → whether the run could wait for an emailed code @pytest.fixture @@ -172,10 +173,11 @@ async def ensure_full_description(job): return job async def apply_with_browser(job_url, persona, cover_letter, queue, frames, *, job_email, job_password, cv_path, - cover_letter_path, answerer, decide): + cover_letter_path, answerer, decide, ask_code=None): company = next(name for name, url in URLS.items() if url == job_url) w.filled.append(company) w.cv_paths.append(cv_path) + w.asks_for_codes[company] = ask_code is not None assert cover_letter.startswith("Dear Hiring Manager,") and os.path.exists(cover_letter_path) form = w.forms[company] yield {"action": "Step 1: filling", "done": False} @@ -188,6 +190,14 @@ async def apply_with_browser(job_url, persona, cover_letter, queue, frames, *, j if send: final.update(submit_attempted=True, submitted=form["confirm"], submit_detail="Thanks for applying", confirm_screenshot=JPEG, code_asked=form.get("code", "")) + tries = 0 # as browser_agent._finish_unattended: ask, enter, send again, up to three codes + while final["code_asked"] and ask_code is not None and tries < 3: + tries += 1 + given = await ask_code(final["code_asked"], tries) + if not given: + break + if given == form.get("right_code"): + final.update(submitted=True, code_asked="") yield final monkeypatch.setattr(agent, "execute_tool", execute_tool) @@ -195,6 +205,7 @@ async def apply_with_browser(job_url, persona, cover_letter, queue, frames, *, j monkeypatch.setattr(jobs_db, "ensure_full_description", ensure_full_description) monkeypatch.setattr(browser_agent, "apply_with_browser", apply_with_browser) monkeypatch.setattr(doc_check, "check_facts", lambda *a, **k: []) + monkeypatch.setattr(autopilot, "_CODE_WAIT_S", 0.05) # nobody types a code in unless a test does jobs_db.add_jobs([make_job(i, f"Graduate Engineer {name}", name, url=url, description="Python") for i, (name, url) in enumerate(URLS.items())]) return w @@ -273,18 +284,66 @@ def test_a_click_the_page_doesnt_confirm_is_not_counted_as_applied(ada, world): assert autopilot.sent_last_day(ada) == 3 # still counts toward the limit +ASKED = ("A verification code was sent to ada@example.com. To submit your application, enter the 8-character code to " + "confirm you're a human.") + + def test_a_site_that_emails_a_code_holds_the_application_for_the_user(ada, world): - asked = ("A verification code was sent to ada@example.com. To submit your application, enter the 8-character " - "code to confirm you're a human.") - world.forms["alpha"].update(confirm=False, code=asked) - _run(ada, live=True) + world.forms["alpha"].update(confirm=False, code=ASKED) + _run(ada, live=True) # nobody types the code in run = _runs(ada)["alpha"] - assert run["outcome"] == "needs_you" and "wasn't sent" in run["reasons"][0] and asked in run["reasons"][0] + assert run["outcome"] == "needs_you" and ASKED in run["reasons"][0] + assert "no code was typed in" in run["reasons"][0] and "wasn't sent" in run["reasons"][0] app = {a["company"]: a for a in tracker.list_applications(ada)}["alpha"] assert app["status"] == "Pending Review" and "asked for a code it emailed you" in app["notes"] assert autopilot.sent_last_day(ada) == 2 # beta and gamma: alpha didn't go +def test_a_code_typed_into_windrush_sends_the_form_held_open_for_it(ada, world, monkeypatch): + monkeypatch.setattr(autopilot, "_CODE_WAIT_S", 5) + world.forms["alpha"].update(confirm=False, code=ASKED, right_code="AB12CD34") + autopilot.save_settings(ada, {"live": True}) + seen: list[dict] = [] + + async def type_in(code: str, after: asyncio.Future | None = None) -> asyncio.Future: + # Wait for the form to be held open for a code (a new wait, not the one answered last), then type it in. + while not autopilot._codes or next(iter(autopilot._codes.values())) is after: + await asyncio.sleep(0.01) + run_id, waiting = next(iter(autopilot._codes.items())) + seen.append(tracker.get_autopilot_run(ada, run_id)) + assert autopilot.give_code(run_id, code) + return waiting + + async def main(): + task = asyncio.create_task(autopilot.run(ada)) + first = await asyncio.wait_for(type_in("WRONG123"), 5) # a code that doesn't take: it asks again + await asyncio.wait_for(type_in("AB12CD34", after=first), 5) + return await task + asyncio.run(main()) + + assert [r["outcome"] for r in seen] == ["awaiting_code", "awaiting_code"] + assert ASKED in seen[0]["reasons"][0] and "Type it in here within" in seen[0]["reasons"][0] + assert "The last code didn't work" in seen[1]["reasons"][0] + run = _runs(ada)["alpha"] + assert run["outcome"] == "submitted" and "Sent after you typed in the code" in run["notes"] + assert {a["company"]: a["status"] for a in tracker.list_applications(ada)}["alpha"] == "Applied" + assert autopilot._codes == {} + + +def test_a_practice_run_never_waits_for_a_code(ada, world): + _run(ada) + assert world.asks_for_codes == {"alpha": False, "beta": False, "gamma": False} + _run(ada, live=True) + assert world.asks_for_codes == {"alpha": True, "beta": True, "gamma": True} + + +def test_a_restart_while_a_form_waits_for_its_code_holds_it(ada, world): + run_id = tracker.start_autopilot_run(ada, {"id": "j1", "title": "Engineer", "company": "alpha"}, "awaiting_code") + tracker.finish_stale_autopilot_runs() + run = tracker.get_autopilot_run(ada, run_id) + assert run["outcome"] == "needs_you" and "restarted while the form waited for the code" in run["reasons"][0] + + def test_poor_fits_and_stretches_are_skipped_without_filling(ada, world): world.fit["alpha"] = (60, "strong") world.fit["beta"] = (95, "reach") @@ -402,6 +461,23 @@ def test_a_handover_pack_holds_what_applying_by_hand_needs(ada, world, client_fo assert "for a form that wants it pasted:\n\nDear Hiring Manager,\n\nI'd like to join." in sheet +def test_the_code_endpoint_hands_a_typed_code_to_the_waiting_form(ada, world, client_for, monkeypatch): + run_id = tracker.start_autopilot_run(ada, {"id": "j1", "title": "Engineer", "company": "alpha"}, "awaiting_code") + handed: list[tuple[str, str]] = [] + monkeypatch.setattr(autopilot, "give_code", lambda rid, code: handed.append((rid, code)) or True) + client = client_for(ada) + assert client.post(f"/autopilot/runs/{run_id}/code", json={"code": " ab12-cd34 "}).json() == {"accepted": True} + assert handed == [(run_id, "ab12cd34")] + assert client.post(f"/autopilot/runs/{run_id}/code", json={"code": "12"}).status_code == 400 + assert client.post(f"/autopilot/runs/{run_id}/code", json={"code": "