mirror of
https://github.com/feder-cr/Jobs_Applier_AI_Agent.git
synced 2026-11-07 10:36:05 -10:00
A select, click or key press that leaves the site returns before the new page has begun to load, so the address read right after it could still be the form's and the form went on. The page is now read again, for a short while, until its document has changed (its time origin) or its address has. A page that stays the same is the answer, not a fault. Each screenshot the model is sent stayed in every later call. Now only the latest one stays, and an older one is a note that it was taken. The model acts on the latest picture.
712 lines
36 KiB
Python
712 lines
36 KiB
Python
"""The rule that matters: the model cannot send an application, and only the person's yes does, once for the selection."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
from collections.abc import AsyncIterator
|
|
from contextlib import asynccontextmanager
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from pypdf import PdfReader
|
|
|
|
from aihawk import apply as apply_module
|
|
from aihawk.apply import (
|
|
FINISH_TOOL,
|
|
MODEL_TOOLS,
|
|
PAGE_CHARS,
|
|
SYSTEM,
|
|
Outcome,
|
|
apply_to_job,
|
|
fill_form,
|
|
instructions,
|
|
offered,
|
|
refusal,
|
|
run_on_page,
|
|
sends_form,
|
|
sign_in,
|
|
)
|
|
from aihawk.database import Job
|
|
from aihawk.documents import score_from, score_job, tailor_cv, write_cv_pdf
|
|
from fakes import ScriptedBrowser, ScriptedModel, tool_call
|
|
|
|
JOB = Job("g:a:1", "Acme", "Engineer", "Rome", "https://u", "https://u/apply", "Build things")
|
|
SUBMIT = {"tag": "button", "type": "submit", "in_form": True, "text": "Submit application"}
|
|
VERIFIED = json.dumps({"outcome": "submitted", "evidence": "received"})
|
|
|
|
|
|
def ready(selector: str = "#send") -> list[Any]:
|
|
return [tool_call("finish_form", status="ready", summary="filled", submit_selector=selector, questions=[])]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("element", "touched", "sends"),
|
|
[
|
|
(SUBMIT, False, True),
|
|
({"tag": "input", "type": "submit", "in_form": True, "text": ""}, False, True),
|
|
({"tag": "button", "type": "", "in_form": True, "text": ""}, False, True),
|
|
({"tag": "div", "type": "", "in_form": False, "text": "Invia candidatura"}, False, True),
|
|
({"tag": "a", "type": "", "in_form": False, "text": "Submit"}, False, True),
|
|
({"tag": "button", "type": "button", "in_form": False, "text": "Apply"}, True, True),
|
|
({"tag": "button", "type": "", "in_form": True, "text": "Apply now"}, False, True),
|
|
({"tag": "a", "type": "", "in_form": False, "text": "Apply for this job"}, False, False),
|
|
({"tag": "button", "type": "button", "in_form": False, "text": "Apply"}, False, False),
|
|
({"tag": "button", "type": "submit", "in_form": True, "text": "Next"}, True, False),
|
|
({"tag": "button", "type": "", "in_form": True, "text": "Yes"}, True, False),
|
|
({"tag": "button", "type": "", "in_form": True, "text": "No"}, True, False),
|
|
({"tag": "button", "type": "", "in_form": True, "text": "Upload File"}, True, False),
|
|
# Gmail's send control is a role=button div outside any form: its text is "Send", or "Schedule send" in the menu.
|
|
({"tag": "div", "type": "", "in_form": False, "text": "Send"}, True, True),
|
|
({"tag": "div", "type": "", "in_form": False, "text": "Schedule send"}, True, True),
|
|
({"tag": "button", "type": "button", "in_form": True, "text": "Submit application"}, True, True),
|
|
({"tag": "button", "type": "", "in_form": True, "text": "Presenta candidatura"}, True, True),
|
|
({"tag": "button", "type": "", "in_form": True, "text": "Submit Application"}, True, True),
|
|
({"tag": "button", "type": "button", "in_form": True, "text": "Add another"}, True, False),
|
|
({"tag": "input", "type": "checkbox", "in_form": True, "text": ""}, True, False),
|
|
# A captcha's box is an iframe in the page: its own words are not the page's, and nothing in it is a send.
|
|
({"tag": "iframe", "type": "", "in_form": True, "text": ""}, True, False),
|
|
({"tag": "iframe", "type": "", "in_form": False, "text": "Widget containing a Cloudflare security challenge"}, True, False),
|
|
({"tag": "iframe", "type": "", "in_form": True, "text": "reCAPTCHA"}, False, False),
|
|
],
|
|
)
|
|
def test_sends_form(element: dict[str, Any], touched: bool, sends: bool) -> None:
|
|
assert sends_form(element, touched) is sends
|
|
|
|
|
|
@pytest.mark.parametrize("word", ["Send", "Senden", "Inviar", "Verzenden", "Versturen", "Skicka", "Wyślij", "Odeslat", "Отправить", "送信", "提交", "제출"])
|
|
def test_a_control_that_sends_in_another_language_is_held_back_even_with_no_form_around_it(word: str) -> None:
|
|
assert sends_form({"tag": "div", "type": "", "in_form": False, "text": word}, True)
|
|
|
|
|
|
@pytest.mark.parametrize("key", ["Enter", "enter", "Control+Enter"])
|
|
def test_enter_is_refused(key: str) -> None:
|
|
assert asyncio.run(refusal(ScriptedBrowser(), "browser_press_key", {"key": key}, False))
|
|
|
|
|
|
def test_other_keys_pass() -> None:
|
|
assert asyncio.run(refusal(ScriptedBrowser(), "browser_press_key", {"key": "Tab"}, False)) is None
|
|
|
|
|
|
def test_a_new_tool_of_the_server_is_offered_if_it_only_reads_and_held_back_if_it_acts() -> None:
|
|
reading = SimpleNamespace(name="browser_something_new", annotations=SimpleNamespace(readOnlyHint=True))
|
|
acting = SimpleNamespace(name="browser_something_new", annotations=SimpleNamespace(readOnlyHint=False))
|
|
silent = SimpleNamespace(name="browser_something_new", annotations=None)
|
|
assert offered(reading)
|
|
assert not offered(acting)
|
|
assert not offered(silent)
|
|
assert offered(SimpleNamespace(name="browser_click", annotations=None))
|
|
|
|
|
|
def test_a_click_that_cannot_be_checked_is_refused() -> None:
|
|
assert asyncio.run(refusal(ScriptedBrowser(), "browser_click", {"selector": "text=Whatever"}, False))
|
|
|
|
|
|
def test_the_model_is_not_offered_what_cannot_be_checked() -> None:
|
|
assert not MODEL_TOOLS & {"browser_open", "browser_close"}
|
|
assert {"browser_click_at", "browser_take_screenshot"} <= MODEL_TOOLS
|
|
assert FINISH_TOOL["function"]["name"] == "finish_form"
|
|
|
|
|
|
def run(model: ScriptedModel, browser: Any, shot: Path, guidance: str = "", reaches: frozenset[str] = frozenset()) -> Outcome:
|
|
return asyncio.run(run_on_page(browser, [], model, "m", JOB, "cv", "answers", Path("cv.pdf"), shot, guidance, reaches=reaches)) # type: ignore[arg-type]
|
|
|
|
|
|
def test_the_models_own_click_on_submit_never_reaches_the_browser(tmp_path: Path) -> None:
|
|
browser = ScriptedBrowser({"#send": SUBMIT})
|
|
model = ScriptedModel([tool_call("browser_click", selector="#send")], ready(), VERIFIED)
|
|
outcome = run(model, browser, tmp_path / "proof.png")
|
|
assert outcome.status == "submitted"
|
|
# The one click is the program's, after the form was named ready: the model's was refused.
|
|
assert browser.clicks() == ["#send"]
|
|
refused = [message for message in model.requests[1]["messages"] if message.get("role") == "tool"]
|
|
assert "Refused" in refused[0]["content"]
|
|
|
|
|
|
def test_a_send_is_clicked_once_and_the_page_confirms_it_with_no_question(tmp_path: Path) -> None:
|
|
browser = ScriptedBrowser({"#send": SUBMIT})
|
|
outcome = run(ScriptedModel(ready(), VERIFIED), browser, tmp_path / "proof.png")
|
|
assert outcome.status == "submitted"
|
|
assert browser.clicks() == ["#send"]
|
|
|
|
|
|
def test_a_click_the_page_does_not_confirm_is_not_called_sent(tmp_path: Path) -> None:
|
|
verdict = json.dumps({"outcome": "unknown", "evidence": "page unchanged"})
|
|
assert run(ScriptedModel(ready(), verdict), ScriptedBrowser({"#send": SUBMIT}), tmp_path / "proof.png").status == "unconfirmed"
|
|
|
|
|
|
class ButtonDisablesItself(ScriptedBrowser):
|
|
"""A form that disables its button as it sends: the click answers with an error, and may well have gone."""
|
|
|
|
async def call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> Any:
|
|
result = await super().call_tool(name, arguments)
|
|
if name == "browser_click":
|
|
return SimpleNamespace(isError=True, content=[SimpleNamespace(text="the action went out but did not reach the element")])
|
|
return result
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("verdict", "status"),
|
|
[("submitted", "submitted"), ("unknown", "unconfirmed")],
|
|
)
|
|
def test_a_click_that_answers_with_an_error_is_never_called_failed_because_it_may_have_gone(verdict: str, status: str, tmp_path: Path) -> None:
|
|
browser = ButtonDisablesItself({"#send": SUBMIT})
|
|
outcome = run(ScriptedModel(ready(), json.dumps({"outcome": verdict, "evidence": "the page"})), browser, tmp_path / "proof.png")
|
|
assert outcome.status == status
|
|
assert browser.clicks() == ["#send"]
|
|
|
|
|
|
def test_a_form_that_refuses_it_comes_back_as_a_refusal_with_the_question(tmp_path: Path) -> None:
|
|
verdict = json.dumps({"outcome": "rejected", "evidence": "phone is required"})
|
|
outcome = run(ScriptedModel(ready(), verdict), ScriptedBrowser({"#send": SUBMIT}), tmp_path / "proof.png")
|
|
assert (outcome.status, outcome.questions) == ("refused", ["phone is required"])
|
|
|
|
|
|
def test_what_the_files_do_not_say_comes_back_as_questions_and_nothing_is_sent(tmp_path: Path) -> None:
|
|
browser = ScriptedBrowser()
|
|
step = [tool_call("finish_form", status="needs_input", summary="missing", submit_selector="", questions=["Salary?"])]
|
|
outcome = run(ScriptedModel(step), browser, tmp_path / "proof.png")
|
|
assert (outcome.status, outcome.questions) == ("needs_input", ["Salary?"])
|
|
assert browser.clicks() == []
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"step",
|
|
[
|
|
[tool_call("finish_form", status="blocked", summary="captcha", submit_selector="", questions=[])],
|
|
[tool_call("finish_form", status="ready", summary="no selector", submit_selector="", questions=[])],
|
|
],
|
|
)
|
|
def test_blocked_or_no_control_sends_nothing(step: list[Any], tmp_path: Path) -> None:
|
|
browser = ScriptedBrowser()
|
|
assert run(ScriptedModel(step), browser, tmp_path / "proof.png").status == "blocked"
|
|
assert browser.clicks() == []
|
|
|
|
|
|
def test_a_tool_the_model_was_not_offered_is_refused() -> None:
|
|
browser = ScriptedBrowser()
|
|
report = asyncio.run(fill_form(browser, [], ScriptedModel([tool_call("browser_close")], ready()), "m", "brief")) # type: ignore[arg-type]
|
|
assert report["status"] == "ready"
|
|
assert browser.calls == []
|
|
|
|
|
|
class PageAtAPoint:
|
|
"""A page where every point holds the same element, and every tool answers with a picture and no text."""
|
|
|
|
def __init__(self, element: dict[str, Any]) -> None:
|
|
self.element = element
|
|
self.calls: list[tuple[str, dict[str, Any]]] = []
|
|
|
|
async def call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> Any:
|
|
self.calls.append((name, arguments or {}))
|
|
if name == "browser_evaluate":
|
|
return SimpleNamespace(isError=False, content=[SimpleNamespace(type="text", text=json.dumps(self.element))])
|
|
return SimpleNamespace(isError=False, content=[SimpleNamespace(type="image", mimeType="image/png", data="QUJD")])
|
|
|
|
|
|
def test_a_click_at_a_point_on_the_submit_control_is_refused_and_it_asks_about_that_point() -> None:
|
|
browser = PageAtAPoint(SUBMIT)
|
|
assert asyncio.run(refusal(browser, "browser_click_at", {"x": 10, "y": 20}, True))
|
|
assert "elementFromPoint(10.0, 20.0)" in browser.calls[0][1]["expression"]
|
|
|
|
|
|
def test_a_click_at_a_point_on_anything_else_passes() -> None:
|
|
field = {"tag": "input", "type": "text", "in_form": True, "text": ""}
|
|
assert asyncio.run(refusal(PageAtAPoint(field), "browser_click_at", {"x": 10, "y": 20}, True)) is None
|
|
|
|
|
|
def test_a_click_at_a_captcha_box_in_its_frame_passes() -> None:
|
|
frame = {"tag": "iframe", "type": "", "in_form": True, "text": ""}
|
|
assert asyncio.run(refusal(PageAtAPoint(frame), "browser_click_at", {"x": 10, "y": 20}, True)) is None
|
|
|
|
|
|
class CaptchaPage(ScriptedBrowser):
|
|
"""A form with a captcha box in a frame at one point, and a send control; the page stays on the form's address."""
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__({"#send": SUBMIT, "#apply": {"tag": "button", "type": "button", "in_form": False, "text": "Apply"}}, page={"url": "https://u/apply", "text": "Form"})
|
|
|
|
async def call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> SimpleNamespace:
|
|
if name == "browser_evaluate" and "elementFromPoint" in (arguments or {}).get("expression", ""):
|
|
self.calls.append((name, arguments or {}))
|
|
frame = {"tag": "iframe", "type": "", "in_form": True, "text": ""}
|
|
return SimpleNamespace(isError=False, content=[SimpleNamespace(text=json.dumps(frame))])
|
|
return await super().call_tool(name, arguments)
|
|
|
|
|
|
def test_a_captcha_box_is_clicked_twice_at_most_and_the_form_is_still_sent_after_it(tmp_path: Path) -> None:
|
|
browser = CaptchaPage()
|
|
captcha = tool_call("browser_click_at", x=40, y=50)
|
|
model = ScriptedModel([captcha], [tool_call("browser_click_at", call_id="again", x=40, y=50)], [tool_call("browser_click", selector="#apply")], ready(), VERIFIED)
|
|
outcome = run(model, browser, tmp_path / "proof.png")
|
|
assert outcome.status == "submitted"
|
|
assert [name for name, _ in browser.calls].count("browser_click_at") == 2
|
|
# The box is not typed text: it does not count as touching the form, so an Apply outside it stays a link.
|
|
assert browser.clicks() == ["#apply", "#send"]
|
|
|
|
|
|
def test_script_is_offered_as_the_last_rung_and_refusal_does_not_check_it() -> None:
|
|
tool = SimpleNamespace(name="browser_evaluate", annotations=SimpleNamespace(readOnlyHint=True))
|
|
assert offered(tool)
|
|
assert "browser_evaluate" in MODEL_TOOLS
|
|
# Known gap, written down: a script that sends the form is not refused, by design of the server's own check.
|
|
send = {"expression": "document.querySelector('#send').requestSubmit?.()"}
|
|
assert asyncio.run(refusal(PageAtAPoint(SUBMIT), "browser_evaluate", send, True)) is None
|
|
|
|
|
|
@pytest.mark.parametrize("arguments", [{"key": " "}, {"key": "Space"}, {"renamed": "space"}])
|
|
def test_space_on_the_submit_control_is_refused(arguments: dict[str, str]) -> None:
|
|
browser = PageAtAPoint(SUBMIT)
|
|
assert asyncio.run(refusal(browser, "browser_press_key", arguments, True))
|
|
assert "activeElement" in browser.calls[0][1]["expression"]
|
|
|
|
|
|
def test_space_on_a_checkbox_passes() -> None:
|
|
box = {"tag": "input", "type": "checkbox", "in_form": True, "text": ""}
|
|
assert asyncio.run(refusal(PageAtAPoint(box), "browser_press_key", {"key": " "}, True)) is None
|
|
|
|
|
|
def test_enter_is_refused_whatever_the_argument_is_called() -> None:
|
|
assert asyncio.run(refusal(ScriptedBrowser(), "browser_press_key", {"renamed": "Enter"}, True))
|
|
|
|
|
|
def tag_field(placeholder: str = "", aria_label: str = "", tag: str = "input", kind: str = "text") -> dict[str, Any]:
|
|
return {"tag": tag, "type": kind, "in_form": True, "text": "", "placeholder": placeholder, "aria_label": aria_label}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"placeholder",
|
|
[
|
|
"Type a skill and press enter",
|
|
"Premi invio dopo ogni competenza",
|
|
"Drücke Enter nach jeder Fähigkeit",
|
|
"Appuyez sur Entrée pour ajouter",
|
|
"Pulsa Intro tras cada habilidad",
|
|
],
|
|
)
|
|
def test_enter_is_allowed_in_a_field_whose_own_placeholder_asks_for_it(placeholder: str) -> None:
|
|
browser = PageAtAPoint(tag_field(placeholder))
|
|
assert asyncio.run(refusal(browser, "browser_press_key", {"key": "Enter"}, True)) is None
|
|
assert "activeElement" in browser.calls[0][1]["expression"]
|
|
|
|
|
|
def test_enter_is_allowed_when_the_aria_label_asks_for_it() -> None:
|
|
assert asyncio.run(refusal(PageAtAPoint(tag_field(aria_label="Skills, press enter to add")), "browser_press_key", {"key": "Enter"}, True)) is None
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"element",
|
|
[
|
|
tag_field("Search jobs"),
|
|
tag_field(""),
|
|
tag_field("press enter", tag="button", kind="button"),
|
|
tag_field("press enter", kind="submit"),
|
|
tag_field("press enter", kind="checkbox"),
|
|
tag_field("press enter", tag="div", kind=""),
|
|
],
|
|
)
|
|
def test_enter_is_refused_unless_the_focused_field_is_a_text_input_that_asks_for_it(element: dict[str, Any]) -> None:
|
|
assert asyncio.run(refusal(PageAtAPoint(element), "browser_press_key", {"key": "Enter"}, True))
|
|
|
|
|
|
def test_enter_is_refused_when_the_focus_cannot_be_told() -> None:
|
|
assert asyncio.run(refusal(ScriptedBrowser(), "browser_press_key", {"key": "Enter"}, True))
|
|
|
|
|
|
@pytest.mark.parametrize("key", ["Control+Enter", "Shift+Enter", "Enter+Control", "NumpadEnter", "Return"])
|
|
def test_a_combination_with_enter_is_refused_even_in_the_tag_field(key: str) -> None:
|
|
assert asyncio.run(refusal(PageAtAPoint(tag_field("Type a skill and press enter")), "browser_press_key", {"key": key}, True))
|
|
|
|
|
|
def test_a_textarea_that_asks_for_enter_is_allowed() -> None:
|
|
area = tag_field("press enter to add", tag="textarea", kind="")
|
|
assert asyncio.run(refusal(PageAtAPoint(area), "browser_press_key", {"key": "Enter"}, True)) is None
|
|
|
|
|
|
def test_a_new_line_is_refused_except_in_a_text_area() -> None:
|
|
field = {"tag": "input", "type": "text", "in_form": True, "text": ""}
|
|
area = {"tag": "textarea", "type": "", "in_form": True, "text": ""}
|
|
arguments = {"selector": "#a", "text": "one\ntwo"}
|
|
assert asyncio.run(refusal(PageAtAPoint(field), "browser_type", arguments, True))
|
|
assert asyncio.run(refusal(PageAtAPoint(area), "browser_type", arguments, True)) is None
|
|
assert asyncio.run(refusal(PageAtAPoint(field), "browser_type", {"selector": "#a", "text": "one"}, True)) is None
|
|
|
|
|
|
def test_an_image_input_sends_and_a_word_with_no_edge_is_found() -> None:
|
|
assert sends_form({"tag": "input", "type": "image", "in_form": True, "text": ""}, True)
|
|
assert sends_form({"tag": "div", "type": "", "in_form": False, "text": "送信する"}, True)
|
|
|
|
|
|
def test_a_named_control_that_does_not_send_is_not_clicked_even_after_the_form_is_ready(tmp_path: Path) -> None:
|
|
browser = ScriptedBrowser({"#delete": {"tag": "button", "type": "button", "in_form": True, "text": "Delete my account"}})
|
|
outcome = run(ScriptedModel(ready("#delete")), browser, tmp_path / "proof.png")
|
|
assert outcome.status == "blocked"
|
|
assert browser.clicks() == []
|
|
|
|
|
|
def test_a_failure_to_read_the_result_after_the_click_is_not_a_failed_application(tmp_path: Path) -> None:
|
|
browser = ScriptedBrowser({"#send": SUBMIT})
|
|
outcome = run(ScriptedModel(ready(), "not json"), browser, tmp_path / "proof.png")
|
|
assert outcome.status == "unconfirmed"
|
|
assert browser.clicks() == ["#send"]
|
|
|
|
|
|
def test_a_screenshot_reaches_the_model_after_the_answers_it_must_follow() -> None:
|
|
model = ScriptedModel([tool_call("browser_take_screenshot")], ready())
|
|
asyncio.run(fill_form(PageAtAPoint(SUBMIT), [], model, "m", "brief")) # type: ignore[arg-type]
|
|
last = model.requests[1]["messages"][-2]
|
|
assert last["role"] == "user"
|
|
assert last["content"] == [{"type": "image_url", "image_url": {"url": "data:image/png;base64,QUJD"}}]
|
|
assert model.requests[1]["messages"][-3]["role"] == "tool"
|
|
|
|
|
|
def test_only_the_latest_picture_stays_in_the_prompt_and_an_older_one_is_a_note() -> None:
|
|
model = ScriptedModel([tool_call("browser_take_screenshot")], [tool_call("browser_take_screenshot")], ready())
|
|
asyncio.run(fill_form(PageAtAPoint(SUBMIT), [], model, "m", "brief")) # type: ignore[arg-type]
|
|
messages = model.requests[-1]["messages"]
|
|
pictures = [m for m in messages if m.get("role") == "user" and isinstance(m.get("content"), list)]
|
|
assert len(pictures) == 1
|
|
assert sum(isinstance(m.get("content"), str) and "taken here" in m["content"] for m in messages) == 1
|
|
|
|
|
|
def test_the_servers_way_of_driving_a_page_comes_after_the_rules_and_before_the_site() -> None:
|
|
text = instructions("About a site.", "Try selectors first.")
|
|
assert text.index(SYSTEM) < text.index("Try selectors first.") < text.index("About a site.")
|
|
|
|
|
|
def test_a_model_that_never_finishes_is_stopped_with_what_it_did_and_nothing_is_sent() -> None:
|
|
told = tool_call("finish_form", status="blocked", summary="typed Test into First name", submit_selector="", questions=[])
|
|
model = ScriptedModel(*["thinking"] * 80, [told])
|
|
report = asyncio.run(fill_form(ScriptedBrowser(), [], model, "m", "brief")) # type: ignore[arg-type]
|
|
assert report["status"] == "blocked"
|
|
assert report["summary"].startswith("Stopped: the form was not finished in 80 steps.") and "typed Test into First name" in report["summary"]
|
|
assert model.requests[-1]["tool_choice"]["function"]["name"] == "finish_form"
|
|
|
|
|
|
def test_the_same_refused_call_three_times_stops_the_form_with_its_questions_and_is_never_run() -> None:
|
|
click = [tool_call("browser_click", selector="#a >> #b")]
|
|
told = tool_call("finish_form", status="needs_input", summary="typed Test", submit_selector="", questions=["Which office?"])
|
|
model = ScriptedModel(click, click, click, [told])
|
|
browser = ScriptedBrowser()
|
|
report = asyncio.run(fill_form(browser, [], model, "m", "brief")) # type: ignore[arg-type]
|
|
assert (report["status"], report["questions"]) == ("needs_input", ["Which office?"])
|
|
assert report["summary"].startswith("Stopped: the same call was refused three times")
|
|
assert not [name for name, _ in browser.calls if name == "browser_click"]
|
|
# The model's list is kept whole, so the second refusal is the second tool message, and only that one is told once.
|
|
answers = [m["content"] for m in model.requests[-1]["messages"] if m.get("role") == "tool"]
|
|
assert "twice" in answers[1] and "twice" not in answers[0] and "stopped" in answers[2]
|
|
|
|
|
|
def test_a_refused_selector_is_told_to_be_clicked_by_its_centre_instead() -> None:
|
|
message = asyncio.run(refusal(ScriptedBrowser(), "browser_click", {"selector": "#a >> #b"}, False))
|
|
assert message and "browser_click_at" in message and "shadow" in message
|
|
|
|
|
|
def test_the_plugins_text_reaches_the_verifying_model_as_its_rules_while_the_page_stays_text_to_check(tmp_path: Path) -> None:
|
|
model = ScriptedModel(ready(), VERIFIED)
|
|
guidance = "After sending: a page that says Thank you has sent it."
|
|
run(model, ScriptedBrowser({"#send": SUBMIT}), tmp_path / "proof.png", guidance)
|
|
verifying = model.requests[-1]["messages"]
|
|
assert guidance in verifying[0]["content"]
|
|
assert verifying[1]["content"] == "Thank you, your application was received"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("verdict", "status"),
|
|
[("submitted", "submitted"), ("unknown", "unconfirmed"), ("rejected", "refused")],
|
|
)
|
|
def test_a_click_keeps_its_time_address_verdict_text_and_picture(verdict: str, status: str, tmp_path: Path) -> None:
|
|
browser = ScriptedBrowser({"#send": SUBMIT}, page={"url": "https://u/thanks", "text": ""})
|
|
shot = tmp_path / "proofs" / "g-a-1.png"
|
|
outcome = run(ScriptedModel(ready(), json.dumps({"outcome": verdict, "evidence": "the page"})), browser, shot)
|
|
assert outcome.status == status
|
|
proof = outcome.proof
|
|
assert (proof.verdict, proof.evidence, proof.url) == (verdict, "the page", "https://u/thanks")
|
|
assert proof.at and proof.text == "Thank you, your application was received"
|
|
assert proof.screenshot == str(shot) and shot.read_bytes() == b"PNG"
|
|
|
|
|
|
class LongPage(ScriptedBrowser):
|
|
async def call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> Any:
|
|
if name == "browser_read_text":
|
|
return SimpleNamespace(isError=False, content=[SimpleNamespace(text="x" * 9000)])
|
|
return await super().call_tool(name, arguments)
|
|
|
|
|
|
def test_the_page_text_kept_is_a_few_thousand_characters_at_most(tmp_path: Path) -> None:
|
|
outcome = run(ScriptedModel(ready(), VERIFIED), LongPage({"#send": SUBMIT}), tmp_path / "proof.png")
|
|
assert len(outcome.proof.text) == PAGE_CHARS
|
|
|
|
|
|
class NoPicture(ScriptedBrowser):
|
|
"""A page whose picture cannot be taken: the tool answers with an error, or fails outright."""
|
|
|
|
def __init__(self, fails: bool) -> None:
|
|
super().__init__({"#send": SUBMIT})
|
|
self.fails = fails
|
|
|
|
async def call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> Any:
|
|
if name == "browser_take_screenshot":
|
|
if self.fails:
|
|
raise RuntimeError("the window is gone")
|
|
return SimpleNamespace(isError=True, content=[SimpleNamespace(text="no page")])
|
|
return await super().call_tool(name, arguments)
|
|
|
|
|
|
@pytest.mark.parametrize("fails", [True, False])
|
|
def test_a_picture_that_cannot_be_taken_changes_no_verdict(fails: bool, tmp_path: Path) -> None:
|
|
shot = tmp_path / "proof.png"
|
|
outcome = run(ScriptedModel(ready(), VERIFIED), NoPicture(fails), shot)
|
|
assert outcome.status == "submitted"
|
|
assert outcome.proof.screenshot == "" and not shot.exists()
|
|
|
|
|
|
class ScriptedServer(ScriptedBrowser):
|
|
"""The browser server as the tests see it: every call is recorded, and there are no tools to list."""
|
|
|
|
async def list_tools(self) -> SimpleNamespace:
|
|
return SimpleNamespace(tools=[])
|
|
|
|
|
|
def scripted_server(monkeypatch: pytest.MonkeyPatch, server: ScriptedServer) -> None:
|
|
@asynccontextmanager
|
|
async def started(upload_dir: Path, home: Path) -> AsyncIterator[tuple[ScriptedServer, str]]:
|
|
yield server, ""
|
|
|
|
monkeypatch.setattr(apply_module, "_server", started)
|
|
|
|
|
|
def test_the_profile_reaches_browser_open_and_the_browser_is_closed(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
server = ScriptedServer()
|
|
scripted_server(monkeypatch, server)
|
|
profile = tmp_path / "browser-profile"
|
|
model = ScriptedModel([tool_call("finish_form", status="blocked", summary="closed", submit_selector="", questions=[])])
|
|
outcome = asyncio.run(apply_to_job(model, "m", JOB, "cv", "answers", tmp_path / "cv.pdf", tmp_path, profile)) # type: ignore[arg-type]
|
|
assert outcome.status == "blocked"
|
|
assert server.calls[0] == ("browser_open", {"profile": str(profile)})
|
|
assert server.calls[-1] == ("browser_close", {})
|
|
|
|
|
|
def test_a_sent_form_keeps_its_picture_under_data_proofs(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
scripted_server(monkeypatch, ScriptedServer({"#send": SUBMIT}))
|
|
outcome = asyncio.run(apply_to_job(ScriptedModel(ready(), VERIFIED), "m", JOB, "cv", "answers", tmp_path / "cv.pdf", tmp_path, tmp_path / "profile")) # type: ignore[arg-type]
|
|
assert outcome.status == "submitted"
|
|
assert outcome.proof.screenshot == str(tmp_path / "proofs" / "g-a-1.png")
|
|
assert (tmp_path / "proofs" / "g-a-1.png").read_bytes() == b"PNG"
|
|
|
|
|
|
def test_sign_in_opens_the_profile_on_a_blank_page_and_closes_it_when_the_person_is_done(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
server = ScriptedServer()
|
|
scripted_server(monkeypatch, server)
|
|
profile = tmp_path / "browser-profile"
|
|
waited: list[tuple[str, ...]] = []
|
|
|
|
async def wait() -> None:
|
|
waited.append(tuple(name for name, _ in server.calls))
|
|
|
|
asyncio.run(sign_in(profile, tmp_path, wait))
|
|
assert waited == [("browser_open", "browser_navigate")]
|
|
assert server.calls == [("browser_open", {"profile": str(profile)}), ("browser_navigate", {"url": "about:blank"}), ("browser_close", {})]
|
|
|
|
|
|
def test_sign_in_closes_the_browser_even_when_the_wait_fails(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
server = ScriptedServer()
|
|
scripted_server(monkeypatch, server)
|
|
|
|
async def broken() -> None:
|
|
raise RuntimeError("the window was closed")
|
|
|
|
with pytest.raises(RuntimeError, match="window was closed"):
|
|
asyncio.run(sign_in(tmp_path / "browser-profile", tmp_path, broken))
|
|
assert server.calls[-1] == ("browser_close", {})
|
|
|
|
|
|
FORM_PAGE = {"url": "https://u/apply", "text": "Apply here"}
|
|
SELECT = [tool_call("browser_select_option", selector="#pick", value="a")]
|
|
|
|
|
|
class Moves(ScriptedBrowser):
|
|
"""A form whose select, when changed, puts the page where the test says: a change event that leaves, or confirms.
|
|
The first `unreadable` reads of the page after the change fail, as a page that is still loading does."""
|
|
|
|
def __init__(self, after: dict[str, str] | None = None, unreadable: int = 0) -> None:
|
|
super().__init__({"#send": SUBMIT}, page=FORM_PAGE)
|
|
self.after = after
|
|
self.unreadable = unreadable
|
|
self.changed = False
|
|
|
|
async def call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> Any:
|
|
if name == "browser_select_option":
|
|
self.changed = True
|
|
self.page = self.after or self.page
|
|
if name == "browser_evaluate" and self.changed and self.unreadable and "location.href" in str(arguments):
|
|
self.unreadable -= 1
|
|
return SimpleNamespace(isError=True, content=[SimpleNamespace(text="the page is loading")])
|
|
return await super().call_tool(name, arguments)
|
|
|
|
|
|
def test_the_model_may_go_to_the_forms_site_and_to_a_mailbox_a_plugin_reaches_and_nowhere_else() -> None:
|
|
browser = ScriptedBrowser()
|
|
mailbox = frozenset({"u", "mail.google.com"})
|
|
assert asyncio.run(refusal(browser, "browser_navigate", {"url": "https://u/other"}, False, mailbox)) is None
|
|
assert asyncio.run(refusal(browser, "browser_navigate", {"url": "https://mail.google.com/mail/u/0/"}, False, mailbox)) is None
|
|
assert asyncio.run(refusal(browser, "browser_navigate", {"url": "https://evil.example/confirm?action=submit"}, False, mailbox))
|
|
|
|
|
|
def test_a_navigation_by_the_model_to_another_site_is_refused_and_never_reaches_the_browser(tmp_path: Path) -> None:
|
|
browser = ScriptedBrowser({"#send": SUBMIT}, page=FORM_PAGE)
|
|
model = ScriptedModel([tool_call("browser_navigate", url="https://evil.example/")], ready(), VERIFIED)
|
|
assert run(model, browser, tmp_path / "proof.png").status == "submitted"
|
|
assert [args["url"] for name, args in browser.calls if name == "browser_navigate"] == []
|
|
|
|
|
|
def test_a_change_event_that_moves_the_page_off_the_form_stops_as_unconfirmed_and_nothing_is_retried(tmp_path: Path) -> None:
|
|
browser = Moves(after={"url": "https://thanks.example/ok", "text": "Thanks"})
|
|
model = ScriptedModel(SELECT, ready(), VERIFIED)
|
|
outcome = run(model, browser, tmp_path / "proof.png")
|
|
assert outcome.status == "unconfirmed"
|
|
assert "thanks.example" in outcome.summary
|
|
assert browser.clicks() == []
|
|
assert len(model.requests) == 1
|
|
|
|
|
|
class ScriptMoves(ScriptedBrowser):
|
|
"""A page whose script, when it runs `location.href = ...`, leaves for another site."""
|
|
|
|
def __init__(self, after: dict[str, str]) -> None:
|
|
super().__init__({"#send": SUBMIT}, page=FORM_PAGE)
|
|
self.after = after
|
|
|
|
async def call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> Any:
|
|
if name == "browser_evaluate" and str((arguments or {}).get("expression", "")).startswith("location.href ="):
|
|
self.calls.append((name, arguments or {}))
|
|
self.page = self.after
|
|
return SimpleNamespace(isError=False, content=[SimpleNamespace(text="ok")])
|
|
return await super().call_tool(name, arguments)
|
|
|
|
|
|
def test_a_script_that_moves_the_page_off_the_form_stops_as_unconfirmed_and_nothing_is_retried(tmp_path: Path) -> None:
|
|
browser = ScriptMoves(after={"url": "https://thanks.example/ok", "text": "Thanks"})
|
|
model = ScriptedModel([tool_call("browser_evaluate", expression="location.href = 'https://thanks.example/ok'")], ready(), VERIFIED)
|
|
outcome = run(model, browser, tmp_path / "proof.png")
|
|
assert outcome.status == "unconfirmed"
|
|
assert "thanks.example" in outcome.summary
|
|
assert browser.clicks() == []
|
|
assert len(model.requests) == 1
|
|
|
|
|
|
def test_a_change_that_stays_on_the_site_goes_on_to_the_form_so_a_same_site_send_is_not_detected(tmp_path: Path) -> None:
|
|
# The address alone cannot tell a multi-step form from a send that stays on the site: this is the gap, written down.
|
|
browser = Moves(after={"url": "https://u/apply", "text": "Thank you, your application was received"})
|
|
model = ScriptedModel(SELECT, ready(), VERIFIED)
|
|
assert run(model, browser, tmp_path / "proof.png").status == "submitted"
|
|
|
|
|
|
@pytest.fixture
|
|
def no_wait(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(apply_module, "READ_WAIT_S", 0)
|
|
|
|
|
|
def test_a_page_still_loading_after_a_change_is_read_again_until_its_host_shows(no_wait: None, tmp_path: Path) -> None:
|
|
browser = Moves(after={"url": "https://thanks.example/ok", "text": "Thanks"}, unreadable=2)
|
|
model = ScriptedModel(SELECT)
|
|
outcome = run(model, browser, tmp_path / "proof.png")
|
|
assert outcome.status == "unconfirmed"
|
|
assert "thanks.example" in outcome.summary
|
|
assert browser.clicks() == []
|
|
assert len(model.requests) == 1
|
|
|
|
|
|
def test_a_page_that_stays_unreadable_after_a_change_does_not_end_the_form(no_wait: None, tmp_path: Path) -> None:
|
|
browser = Moves(after={"url": "https://u/apply", "text": "x"}, unreadable=100)
|
|
model = ScriptedModel(SELECT, ready(), VERIFIED)
|
|
assert run(model, browser, tmp_path / "proof.png").status == "submitted"
|
|
assert browser.clicks() == ["#send"]
|
|
|
|
|
|
class Typing(ScriptedBrowser):
|
|
"""A form still being typed into, as on Breezy: its page cannot be read for a while after the text is typed."""
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__({"#send": SUBMIT}, page=FORM_PAGE)
|
|
self.busy = 0
|
|
|
|
async def call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> Any:
|
|
if name == "browser_type":
|
|
self.busy = 3
|
|
if name == "browser_evaluate" and self.busy and "location.href" in str(arguments):
|
|
self.busy -= 1
|
|
return SimpleNamespace(isError=True, content=[SimpleNamespace(text="still typing")])
|
|
return await super().call_tool(name, arguments)
|
|
|
|
|
|
def test_a_read_that_fails_after_a_type_does_not_end_the_form_and_the_form_is_sent(no_wait: None, tmp_path: Path) -> None:
|
|
browser = Typing()
|
|
model = ScriptedModel([tool_call("browser_type", selector="#name", text="Ada Rossi")], ready(), VERIFIED)
|
|
outcome = run(model, browser, tmp_path / "proof.png")
|
|
assert outcome.status == "submitted"
|
|
assert browser.clicks() == ["#send"]
|
|
assert len(model.requests) == 3
|
|
|
|
|
|
def test_a_mail_trip_to_the_mailbox_and_back_does_not_stop_the_form(tmp_path: Path) -> None:
|
|
class Mail(ScriptedBrowser):
|
|
async def call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> Any:
|
|
if name == "browser_click":
|
|
self.page = {"url": "https://mail.google.com/mail/u/0/#inbox/1", "text": "Your code is 123456"}
|
|
return await super().call_tool(name, arguments)
|
|
|
|
steps = [
|
|
[tool_call("browser_navigate", url="https://mail.google.com/mail/u/0/")],
|
|
[tool_call("browser_click", selector="#msg")],
|
|
[tool_call("browser_navigate", url="https://u/apply")],
|
|
ready(),
|
|
VERIFIED,
|
|
]
|
|
browser = Mail({"#send": SUBMIT, "#msg": {"tag": "a", "type": "", "in_form": False, "text": "Verification code"}}, page=FORM_PAGE)
|
|
model = ScriptedModel(*steps)
|
|
outcome = run(model, browser, tmp_path / "proof.png", reaches=frozenset({"mail.google.com"}))
|
|
assert outcome.status == "submitted"
|
|
# No verdict was asked for the mail click: the five requests are the four steps and the verdict on the submit.
|
|
assert len(model.requests) == 5
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("answers", "score"),
|
|
[
|
|
({"role": "yes", "must_haves": "yes", "level": "yes", "location": "yes"}, 5.0),
|
|
({"role": "yes", "must_haves": "yes", "level": "unclear", "location": "unclear"}, 4.5),
|
|
({"role": "yes", "must_haves": "unclear", "level": "unclear", "location": "unclear"}, 3.5),
|
|
({"role": "unclear", "must_haves": "unclear", "level": "unclear", "location": "unclear"}, 2.5),
|
|
({"role": "yes", "must_haves": "no", "level": "yes", "location": "yes"}, 2.5),
|
|
({"role": "yes", "must_haves": "yes", "level": "yes", "location": "no"}, 1.0),
|
|
({"role": "no", "must_haves": "yes", "level": "yes", "location": "yes"}, 2.5),
|
|
({"role": "no", "must_haves": "no", "level": "no", "location": "no"}, 1.0),
|
|
],
|
|
)
|
|
def test_the_score_comes_from_closed_answers_and_unclear_moves_nothing(answers: dict[str, str], score: float) -> None:
|
|
assert score_from(answers) == score
|
|
|
|
|
|
def test_the_goals_reach_the_model_and_the_score_is_computed_not_asked() -> None:
|
|
reply = {"role": "yes", "must_haves": "yes", "level": "unclear", "location": "unclear", "summary": "s", "gaps": [], "legitimacy": "credible"}
|
|
model = ScriptedModel(json.dumps(reply))
|
|
report = asyncio.run(score_job(model, "m", JOB, "the cv", "remote only")) # type: ignore[arg-type]
|
|
assert report["score"] == 4.5
|
|
assert "remote only" in model.requests[0]["messages"][1]["content"]
|
|
|
|
|
|
def test_tailor_drops_a_code_fence() -> None:
|
|
model = ScriptedModel("```markdown\n# Name\n```")
|
|
assert asyncio.run(tailor_cv(model, "m", JOB, "cv")) == "# Name" # type: ignore[arg-type]
|
|
|
|
|
|
def test_the_pdf_holds_the_text_accents_and_markup_safely(tmp_path: Path) -> None:
|
|
path = tmp_path / "cvs" / "cv.pdf"
|
|
write_cv_pdf("# Niccolò Rossi\n\n## Experience\n- **Lead** at A&B <Corp>\n\nPlain line", path)
|
|
text = " ".join(page.extract_text() for page in PdfReader(path).pages)
|
|
for piece in ("Niccolò Rossi", "Experience", "Lead", "A&B <Corp>", "Plain line"):
|
|
assert piece in text
|