Files
Federico EliaandClaude Fable 5.1 479c7aaf17 The screen, minimal: no status column, no counters, five keys, short words
The owner's brief: people do not want everything full. The list shows a
score, a company, a role, a place, and one sign at most in front: a dot
for selected, a tick for sent; what else happened to a job is in its
details, on Enter, which now also opens a tried job's page and proof.
The heading says what is looked for and nothing else; the counters
"reading", "scoring 3/25" and "N selected" are gone, the scores filling
the list are the progress. Footer: Select, Apply, More, Settings, Quit;
Enter, O and S still work, unshown. The log is four lines and its
sentences a third of their length; every question is one bold line with
its explanation muted under it. Settings put what you change first and
the four models last. The selection mark, double-encoded since it was
committed, is a real dot again.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-07 08:52:42 +01:00

893 lines
39 KiB
Python

"""The screen, driven key by key, with a scripted model and a local database: what a person actually does."""
from __future__ import annotations
import asyncio
import json
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import replace
from pathlib import Path
from types import SimpleNamespace
from typing import Any
import duckdb
import httpx
import pytest
from click.testing import CliRunner
from openai import APIConnectionError, APITimeoutError, AuthenticationError, InternalServerError, RateLimitError
from pypdf import PdfWriter
from textual.widgets import DataTable, Input, RichLog, Static
from aihawk import apply as apply_module
from aihawk import session as session_module
from aihawk.apply import Outcome
from aihawk.cli import main as cli_main
from aihawk.config import ConfigError, load_config
from aihawk.database import Job
from aihawk.llm import explain
from aihawk.profile import install_resume, read_answers, read_resume
from aihawk.session import Session
from aihawk.store import Proof, Store, Stored
from aihawk.tui import AIHawk, JobDetails, SendBatch, SentDetails, SentList, Settings, SignIn, TextPrompt
from fakes import ScriptedBrowser
PROFILE = {
"name": "Ada Rossi",
"plan": {
"nodes": [{"id": "role", "weight": 1.5, "forms": [{"text": "Python Engineer", "weight": 1.0}, {"text": "Python Developer", "weight": 1.0}]}],
"arcs": [],
},
"cities": ["Milan", "Milano"],
"locations": ["Italy"],
"countries": ["it"],
"goals": "Senior Python work.",
"answers": {"email": "[email protected]", "phone": "", "location": "Milan", "linkedin": "", "website": "", "current_company": ""},
}
FIELDS = {"summary": "Fits.", "gaps": ["No Kubernetes"], "legitimacy": "credible"}
CV_TEXT = "# Ada Rossi\n\nSenior Python Engineer at Acme for five years, working on backend services.\n"
def report(role: str, must_haves: str) -> str:
return json.dumps({"role": role, "must_haves": must_haves, "level": "unclear", "location": "unclear", **FIELDS})
def published(folder: Path) -> str:
"""A database as the pipeline publishes it, with two postings that fit and one that does not."""
connection = duckdb.connect()
connection.execute("CREATE TABLE t (key VARCHAR, url VARCHAR, title VARCHAR, company VARCHAR, location VARCHAR, description VARCHAR, posted_at VARCHAR)")
connection.executemany(
"INSERT INTO t VALUES (?, ?, ?, ?, 'Milan', 'A posting.', NULL)",
[
("low", "https://jobs.lever.co/acme/1", "Python Engineer", "Lowly"),
("high", "https://jobs.lever.co/beta/2", "Senior Python Engineer", "Bestco"),
("nosite", "https://nobody.example/3", "Python Engineer", "Noplugin"),
("other", "https://jobs.lever.co/gamma/4", "Accountant", "Gamma"),
],
)
connection.execute("ALTER TABLE t ADD COLUMN IF NOT EXISTS country VARCHAR; ALTER TABLE t ADD COLUMN IF NOT EXISTS remote BOOLEAN")
connection.execute(f"COPY t TO '{(folder / 'jobs_0.parquet').as_posix()}' (FORMAT parquet)")
connection.execute(f"""COPY (SELECT * EXCLUDE (description, file_row_number), 'jobs_0.parquet' AS part, file_row_number AS row_number
FROM read_parquet('{(folder / 'jobs_0.parquet').as_posix()}', file_row_number = true)) TO '{(folder / 'index.parquet').as_posix()}' (FORMAT parquet)""")
manifest = {"rows_out": 4, "built_at": "2026-11-04T00:00:00", "index": {"name": "index.parquet", "bytes": 1}, "files": [{"name": "jobs_0.parquet"}]}
(folder / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
return str(folder / "manifest.json")
@pytest.fixture
def home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
monkeypatch.setenv("ORCAROUTER_API_KEY", "test-key")
folder = tmp_path / "home"
folder.mkdir()
(folder / "aihawk.yaml").write_text(f"database: {published(tmp_path)}\n", encoding="utf-8")
# A home where the mail was already left for later: the first-run question about it has its own tests.
store = Store(folder / "data" / "jobs.db")
store.set_answer("mail_provider", "later")
store.close()
return folder
@pytest.fixture
def resume(tmp_path: Path) -> Path:
path = tmp_path / "resume.txt"
path.write_text(CV_TEXT, encoding="utf-8")
return path
def scripted_app(home: Path, resume: Path | None, **options: Any) -> AIHawk:
app = AIHawk(home, resume, **options)
model = ScriptedModelForApp()
app.session._client = model
return app
class ScriptedModelForApp:
"""Answers each kind of request the way a model would."""
def __init__(self) -> None:
self.chat = SimpleNamespace(completions=SimpleNamespace(create=self._create))
self.requests: list[dict[str, Any]] = []
async def post(self, path: str, *, body: dict[str, Any], cast_to: type) -> dict[str, Any]:
"""The decision endpoint: no opinion on any question, so the evidence is zero and the title alone decides."""
return {"answers": {name: {"type": "noul", "noul": 0.5} for name in body["questions"]}}
async def _create(self, **request: Any) -> Any:
self.requests.append(request)
name = (request.get("response_format") or {}).get("json_schema", {}).get("name")
if name == "search_profile":
content = json.dumps(PROFILE)
elif name == "job_report":
text = request["messages"][1]["content"]
content = report("yes", "yes") if "Bestco" in text else report("yes", "unclear") if "Lowly" in text else report("no", "unclear")
else:
content = "# Ada Rossi"
return SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content=content, tool_calls=None))])
async def settle(pilot: Any, app: AIHawk) -> None:
"""Until the flow's workers are done or waiting on the person."""
for _ in range(200):
await pilot.pause(0.05)
running = [worker for worker in app.workers if worker.is_running and worker.group == "flow"]
if not running or isinstance(app.screen, (TextPrompt, SendBatch)):
return
raise AssertionError("the flow never settled")
def test_a_judged_posting_with_no_text_keeps_its_score_and_the_row_carries_no_status(home: Path) -> None:
app = AIHawk(home)
judged = Stored(Job("x", "Co", "Engineer", "Milan", "u", "u", ""), "scored", 3.0, None, None, None)
read = Stored(Job("y", "Co", "Engineer", "Milan", "u", "u", "A posting."), "scored", 3.0, None, None, None)
# The row says the score and nothing else; that the text was missing is said in the details, on Enter.
assert app.row(judged)[1] == "3.0" and len(app.row(judged)) == len(app.row(read)) == 5
assert str(app.row(judged)[0]) == "" and str(app.row(read)[0]) == ""
def companies(app: AIHawk) -> list[str]:
table = app.query_one(DataTable)
return [str(table.get_row_at(index)[2]) for index in range(table.row_count)]
def test_a_resume_is_all_it_takes_to_get_the_best_jobs_first(home: Path, resume: Path) -> None:
async def run() -> None:
app = scripted_app(home, resume)
async with app.run_test(size=(140, 40)) as pilot:
await settle(pilot, app)
assert companies(app) == ["Bestco", "Lowly", "Noplugin"]
table = app.query_one(DataTable)
assert table.get_row_at(0)[1] == "4.5"
asyncio.run(run())
config = load_config(home)
assert config.filters.include == ("role",)
assert [form.text for form in config.filters.plan.nodes[0].forms] == ["Python Engineer", "Python Developer"]
assert config.filters.locations == ("Milan", "Milano", "Italy")
assert config.filters.cities == ("Milan", "Milano")
assert config.cv.read_text(encoding="utf-8").startswith("# Ada Rossi")
answers = read_answers(config)
assert {name: answers[name] for name in ("email", "location")} == {"email": "[email protected]", "location": "Milan"}
# The one password for the sites that need an account, made at the first start and kept with the answers.
assert len(answers["account_password"]) == 20
def test_no_resume_asks_for_one_and_a_wrong_path_asks_again(home: Path, resume: Path) -> None:
async def run() -> None:
app = scripted_app(home, None)
async with app.run_test(size=(140, 40)) as pilot:
await settle(pilot, app)
assert isinstance(app.screen, TextPrompt)
app.screen.query_one(Input).value = "/no/such/file.pdf"
await pilot.press("enter")
await settle(pilot, app)
assert isinstance(app.screen, TextPrompt)
app.screen.query_one(Input).value = str(resume)
await pilot.press("enter")
await settle(pilot, app)
assert companies(app) == ["Bestco", "Lowly", "Noplugin"]
asyncio.run(run())
def test_no_key_asks_for_one_and_keeps_it_on_this_computer(home: Path, resume: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("ORCAROUTER_API_KEY")
async def run() -> None:
app = AIHawk(home, resume)
async with app.run_test(size=(140, 40)) as pilot:
await settle(pilot, app)
assert isinstance(app.screen, TextPrompt)
assert app.screen.query_one(Input).password is True
app.screen.query_one(Input).value = "sk-orca-typed"
app.session.save_key = lambda key: (type(app.session.config).save_key(app.session.config, key), setattr(app.session, "_client", ScriptedModelForApp())) # type: ignore[method-assign]
await pilot.press("enter")
await settle(pilot, app)
assert companies(app)
asyncio.run(run())
assert (home / "key").read_text(encoding="utf-8") == "sk-orca-typed"
def fake_apply(app: AIHawk, outcomes: list[Outcome], calls: list[Any]) -> None:
async def apply_to_job(client: Any, model: str, job: Any, cv: str, answers: str, cv_file: Path, data_dir: Path, profile: Path, guidance: str = "", reaches: frozenset[str] = frozenset(), key_env: str = "") -> Outcome:
calls.append({"job": job, "answers": answers, "guidance": guidance, "cv_file": cv_file, "profile": profile})
return outcomes.pop(0)
import aihawk.session as session_module
session_module.apply_to_job = apply_to_job # type: ignore[assignment]
@pytest.fixture
def restored_apply() -> Any:
import aihawk.session as session_module
original = session_module.apply_to_job
yield
session_module.apply_to_job = original
async def wait_for_screen(pilot: Any, kind: type) -> None:
for _ in range(100):
await pilot.pause(0.05)
if isinstance(pilot.app.screen, kind):
return
raise AssertionError(f"{kind.__name__} never came")
async def wait_for_apply(pilot: Any, app: AIHawk) -> None:
for _ in range(100):
await pilot.pause(0.05)
if not app.busy:
return
raise AssertionError("applying never finished")
def test_space_space_a_then_enter_sends_each_selected_job_once_and_asks_no_question(home: Path, resume: Path, restored_apply: Any) -> None:
calls: list[Any] = []
async def run() -> None:
app = scripted_app(home, resume)
fake_apply(app, [Outcome("submitted", "sent"), Outcome("submitted", "sent")], calls)
async with app.run_test(size=(140, 40)) as pilot:
await settle(pilot, app)
await pilot.press("space", "space", "a")
await wait_for_screen(pilot, SendBatch)
await pilot.press("enter")
await wait_for_apply(pilot, app)
await pilot.pause(0.1)
assert not isinstance(app.screen, TextPrompt)
table = app.query_one(DataTable)
assert [str(table.get_row_at(index)[0]) for index in range(2)] == ["✓", "✓"]
assert app.selected == set()
asyncio.run(run())
assert [call["job"].company for call in calls] == ["Bestco", "Lowly"]
assert calls[0]["job"].apply_url == "https://jobs.lever.co/beta/2/apply"
assert "Upload the CV" in calls[0]["guidance"]
assert Path(calls[0]["cv_file"]).is_file()
def test_escape_on_the_batch_sends_none_and_the_selection_stays(home: Path, resume: Path, restored_apply: Any) -> None:
calls: list[Any] = []
async def run() -> None:
app = scripted_app(home, resume)
fake_apply(app, [], calls)
async with app.run_test(size=(140, 40)) as pilot:
await settle(pilot, app)
await pilot.press("space", "a")
await wait_for_screen(pilot, SendBatch)
await pilot.press("escape")
await wait_for_apply(pilot, app)
assert app.selected == {"high"}
assert str(app.query_one(DataTable).get_row_at(0)[0]) == "●"
asyncio.run(run())
assert calls == []
def test_a_with_nothing_selected_asks_about_the_current_row_alone(home: Path, resume: Path, restored_apply: Any) -> None:
calls: list[Any] = []
async def run() -> None:
app = scripted_app(home, resume)
fake_apply(app, [Outcome("submitted", "sent")], calls)
async with app.run_test(size=(140, 40)) as pilot:
await settle(pilot, app)
await pilot.press("a")
await wait_for_screen(pilot, SendBatch)
assert len(app.screen.items) == 1
await pilot.press("enter")
await wait_for_apply(pilot, app)
asyncio.run(run())
assert [call["job"].company for call in calls] == ["Bestco"]
def test_a_job_already_sent_is_not_sent_twice(home: Path, resume: Path, restored_apply: Any) -> None:
calls: list[Any] = []
async def run() -> None:
app = scripted_app(home, resume)
fake_apply(app, [Outcome("submitted", "sent")], calls)
async with app.run_test(size=(140, 40)) as pilot:
await settle(pilot, app)
await pilot.press("a")
await wait_for_screen(pilot, SendBatch)
await pilot.press("enter")
await wait_for_apply(pilot, app)
app.query_one(DataTable).move_cursor(row=0)
await pilot.press("a")
await wait_for_apply(pilot, app)
await pilot.pause(0.2)
assert not isinstance(app.screen, SendBatch)
asyncio.run(run())
assert len(calls) == 1
def test_the_auto_submit_flag_is_gone() -> None:
result = CliRunner().invoke(cli_main, ["--auto-submit"])
assert result.exit_code != 0
assert "No such option" in result.output
def test_s_lists_what_was_tried_newest_first_and_enter_opens_one_in_full(home: Path, resume: Path) -> None:
async def run() -> None:
app = scripted_app(home, resume)
store = app.session.store
store.add(Job("sent", "Acme", "Engineer", "Rome", "https://jobs.lever.co/acme/1", "https://jobs.lever.co/acme/1/apply", "d"))
store.set_status("sent", "submitted", "Filled in: the CV")
store.set_proof("sent", Proof("2026-11-03T09:00:00", "https://jobs.lever.co/acme/thanks", "submitted", "Thank you", "the whole page", "/shots/sent.png"))
store.add(Job("stuck", "Beta", "Designer", "Rome", "https://jobs.lever.co/beta/2", "https://jobs.lever.co/beta/2/apply", "d"))
store.set_status("stuck", "blocked", "a captcha")
store.set_proof("stuck", Proof())
# A job with no click is ordered by when its status changed: an older change comes after the send.
store._db.execute("UPDATE jobs SET updated = ? WHERE id = ?", ("2026-11-01T12:00:00", "stuck"))
async with app.run_test(size=(140, 40)) as pilot:
await settle(pilot, app)
await pilot.press("s")
await wait_for_screen(pilot, SentList)
table = app.screen.query_one(DataTable)
rows = [[str(table.get_row_at(index)[column]) for column in (1, 3, 4)] for index in range(table.row_count)]
assert rows[0] == ["Acme", "sent and confirmed", "Thank you"]
assert rows[1][1:] == ["blocked", "a captcha"]
await pilot.press("enter")
await wait_for_screen(pilot, SentDetails)
assert app.screen.item.job.id == "sent"
await pilot.press("escape")
await pilot.press("escape")
asyncio.run(run())
def logged(app: AIHawk) -> str:
return "\n".join(strip.text for strip in app.query_one(RichLog).lines)
def test_s_with_nothing_tried_says_how_to_send(home: Path, resume: Path) -> None:
async def run() -> None:
app = scripted_app(home, resume)
async with app.run_test(size=(140, 40)) as pilot:
await settle(pilot, app)
await pilot.press("s")
await pilot.pause(0.1)
assert not isinstance(app.screen, SentList)
assert "Nothing sent yet" in logged(app)
asyncio.run(run())
def test_each_send_says_how_it_went_and_where_to_read_more(home: Path, resume: Path, restored_apply: Any) -> None:
calls: list[Any] = []
async def run() -> None:
app = scripted_app(home, resume)
fake_apply(app, [Outcome("submitted", "Filled.", proof=Proof("2026-11-05T10:00:00+00:00", "u", "submitted", "The page says thank you.", "t", ""))], calls)
async with app.run_test(size=(200, 40)) as pilot:
await settle(pilot, app)
await pilot.press("a")
await wait_for_screen(pilot, SendBatch)
await pilot.press("enter")
await wait_for_apply(pilot, app)
log = logged(app)
assert "Bestco: sent and confirmed. The page says thank you." in log
asyncio.run(run())
def test_at_80_columns_the_role_is_the_last_column_and_at_100_the_location_is(home: Path, resume: Path) -> None:
async def run() -> None:
for width, labels in ((80, 5), (100, 6)): # the columns before the status column was dropped; one fewer now
app = scripted_app(home, resume)
async with app.run_test(size=(width, 24)) as pilot:
await settle(pilot, app)
table = app.query_one(DataTable)
assert len(table.ordered_columns) == labels - 1 and str(table.ordered_columns[-1].label) == ("Location" if width >= 100 else "Role")
assert sum(column.width + 2 for column in table.ordered_columns) <= width - 2
asyncio.run(run())
def test_a_jobs_details_can_select_it_and_the_row_shows_the_mark(home: Path, resume: Path) -> None:
async def run() -> None:
app = scripted_app(home, resume)
async with app.run_test(size=(140, 40)) as pilot:
await settle(pilot, app)
await pilot.press("enter")
await wait_for_screen(pilot, JobDetails)
await pilot.press("space")
await pilot.pause(0.1)
assert not isinstance(app.screen, JobDetails) and len(app.selected) == 1
assert str(app.query_one(DataTable).get_row_at(0)[0]) == "●"
asyncio.run(run())
def test_enter_shows_why_a_job_fits(home: Path, resume: Path) -> None:
async def run() -> None:
app = scripted_app(home, resume)
async with app.run_test(size=(140, 40)) as pilot:
await settle(pilot, app)
await pilot.press("enter")
await wait_for_screen(pilot, JobDetails)
assert app.screen.item.report["gaps"] == ["No Kubernetes"]
await pilot.press("escape")
asyncio.run(run())
def test_a_second_start_goes_straight_to_the_list_without_asking(home: Path, resume: Path) -> None:
async def run(with_resume: Path | None) -> list[int]:
app = scripted_app(home, with_resume)
async with app.run_test(size=(140, 40)) as pilot:
await settle(pilot, app)
assert not isinstance(app.screen, TextPrompt)
assert companies(app)[0] == "Bestco"
return [len(app.session.client.requests)]
asyncio.run(run(resume))
assert asyncio.run(run(None)) == [0]
@pytest.mark.parametrize("name", ["cv.txt", "cv.md"])
def test_a_text_resume_is_read(tmp_path: Path, name: str) -> None:
path = tmp_path / name
path.write_text(CV_TEXT, encoding="utf-8")
assert read_resume(path).startswith("# Ada")
def test_a_resume_and_the_files_beside_it_saved_with_a_byte_order_mark_are_read(home: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
# Windows editors write a byte-order mark at the start of a text file: it is not part of what the file says.
bom = b"\xef\xbb\xbf"
path = tmp_path / "cv.txt"
path.write_bytes(bom + CV_TEXT.encode("utf-8"))
assert read_resume(path).startswith("# Ada")
(home / "answers.yaml").write_bytes(bom + b"email: [email protected]\n")
(home / "aihawk.yaml").write_bytes(bom + f"database: {published(tmp_path)}\n".encode())
monkeypatch.delenv("ORCAROUTER_API_KEY")
(home / "key").write_bytes(bom + b"sk-orca-typed\n")
config = load_config(home)
assert read_answers(config)["email"] == "[email protected]"
assert config.api_key() == "sk-orca-typed"
def test_a_pdf_with_no_text_says_what_to_do(tmp_path: Path) -> None:
path = tmp_path / "scan.pdf"
writer = PdfWriter()
writer.add_blank_page(100, 100)
with path.open("wb") as out:
writer.write(out)
with pytest.raises(ConfigError, match="scanned PDF"):
read_resume(path)
def test_a_word_resume_is_read(tmp_path: Path) -> None:
import zipfile
path = tmp_path / "cv.docx"
words = "Senior Python Engineer at Acme for five years, on backend services and data."
xml = f'<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>Ada Rossi</w:t></w:r></w:p><w:p><w:r><w:t>{words}</w:t></w:r></w:p></w:body></w:document>'
with zipfile.ZipFile(path, "w") as archive:
archive.writestr("word/document.xml", xml)
assert read_resume(path) == f"Ada Rossi\n{words}"
def test_an_old_word_file_says_what_to_do(tmp_path: Path) -> None:
path = tmp_path / "cv.doc"
path.write_bytes(b"old")
with pytest.raises(ConfigError, match="PDF or .docx"):
read_resume(path)
def test_a_new_resume_never_invents_a_contact_detail(home: Path, resume: Path) -> None:
config = load_config(home)
asyncio.run(install_resume(ScriptedModelForApp(), config, resume)) # type: ignore[arg-type]
assert "phone" not in read_answers(config)
def test_a_new_resume_replaces_the_plan_and_keeps_what_the_person_excluded(home: Path, resume: Path) -> None:
(home / "aihawk.yaml").write_text("filters:\n exclude: [intern]\n", encoding="utf-8")
asyncio.run(install_resume(ScriptedModelForApp(), load_config(home), resume)) # type: ignore[arg-type]
config = load_config(home)
assert config.filters.exclude == ("intern",)
assert config.filters.include == tuple(node.id for node in config.filters.plan.nodes)
def test_the_mail_plugins_join_every_form_after_the_sites_own_and_the_profile_is_the_workspaces(home: Path, resume: Path, restored_apply: Any) -> None:
calls: list[Any] = []
async def run() -> None:
app = scripted_app(home, resume)
fake_apply(app, [Outcome("blocked", "closed")], calls)
async with app.run_test(size=(140, 40)) as pilot:
await settle(pilot, app)
await pilot.press("a")
await wait_for_screen(pilot, SendBatch)
await pilot.press("enter")
await wait_for_apply(pilot, app)
asyncio.run(run())
guidance = calls[0]["guidance"]
assert guidance.index("The form is one page") < guidance.index("mail.google.com") < guidance.index("outlook.live.com")
assert calls[0]["profile"] == home / "browser-profile"
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, key_env: str = "") -> AsyncIterator[tuple[ScriptedServer, str]]:
yield server, ""
monkeypatch.setattr(apply_module, "_server", started)
async def wait_for_sign_in_to_close(pilot: Any, app: AIHawk) -> None:
for _ in range(100):
await pilot.pause(0.05)
if not app.signing_in:
return
raise AssertionError("the sign-in browser never closed")
def test_the_sign_in_key_opens_the_profile_until_the_person_is_done(home: Path, resume: Path, monkeypatch: pytest.MonkeyPatch) -> None:
server = ScriptedServer()
scripted_server(monkeypatch, server)
async def run() -> None:
app = scripted_app(home, resume)
async with app.run_test(size=(140, 40)) as pilot:
await settle(pilot, app)
await pilot.press("l")
await wait_for_screen(pilot, SignIn)
assert ("browser_open", {"profile": str(home / "browser-profile")}) in server.calls
assert server.calls[-1] == ("browser_navigate", {"url": "about:blank"})
await pilot.press("enter")
await wait_for_sign_in_to_close(pilot, app)
asyncio.run(run())
assert server.calls[-1] == ("browser_close", {})
def test_the_sign_in_key_is_refused_while_an_application_runs(home: Path, resume: Path, restored_apply: Any, monkeypatch: pytest.MonkeyPatch) -> None:
server = ScriptedServer()
scripted_server(monkeypatch, server)
import aihawk.session as session_module
async def run() -> None:
release = asyncio.Event()
async def slow_apply(client: Any, model: str, job: Any, cv: str, answers: str, cv_file: Path, data_dir: Path, profile: Path, guidance: str = "", reaches: frozenset[str] = frozenset(), key_env: str = "") -> Outcome:
await release.wait()
return Outcome("blocked", "not sent")
app = scripted_app(home, resume)
session_module.apply_to_job = slow_apply # type: ignore[assignment]
async with app.run_test(size=(140, 40)) as pilot:
await settle(pilot, app)
await pilot.press("a")
await wait_for_screen(pilot, SendBatch)
await pilot.press("enter")
await pilot.pause(0.2)
assert app.applying()
await pilot.press("l")
await pilot.pause(0.2)
assert not app.signing_in
assert not any(name == "browser_open" for name, _ in server.calls)
release.set()
await wait_for_apply(pilot, app)
asyncio.run(run())
REQUEST = httpx.Request("POST", "https://api.orcarouter.ai/v1/chat/completions")
def status_error(kind: type, code: int) -> Exception:
return kind(f"Error code: {code}", response=httpx.Response(code, request=REQUEST), body=None)
class RefusingModel:
"""Every call is refused for its key, as OrcaRouter refuses a wrong one."""
def __init__(self) -> None:
self.chat = SimpleNamespace(completions=SimpleNamespace(create=self._create))
async def _create(self, **request: Any) -> Any:
raise status_error(AuthenticationError, 401)
def log_text(app: AIHawk) -> str:
return "\n".join(line.text for line in app.query_one(RichLog).lines)
def test_a_key_from_the_file_that_orcarouter_refuses_is_forgotten_and_asked_for_again(home: Path, resume: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("ORCAROUTER_API_KEY")
(home / "key").write_text("sk-orca-wrong", encoding="utf-8")
async def run() -> None:
app = AIHawk(home, resume)
app.session._client = RefusingModel() # type: ignore[assignment]
async with app.run_test(size=(140, 40)) as pilot:
await settle(pilot, app)
assert isinstance(app.screen, TextPrompt)
assert "forgot it" in log_text(app) and "orcarouter.ai" in log_text(app)
assert "sk-orca-wrong" not in log_text(app)
await pilot.press("escape")
asyncio.run(run())
assert not (home / "key").exists()
def test_a_key_from_the_environment_that_orcarouter_refuses_is_named_and_not_asked_for(home: Path, resume: Path) -> None:
async def run() -> None:
app = AIHawk(home, resume)
app.session._client = RefusingModel() # type: ignore[assignment]
async with app.run_test(size=(140, 40)) as pilot:
await settle(pilot, app)
assert not isinstance(app.screen, TextPrompt)
assert "ORCAROUTER_API_KEY variable" in log_text(app)
assert "test-key" not in log_text(app)
asyncio.run(run())
@pytest.mark.parametrize(
("error", "words"),
[
(APIConnectionError(request=REQUEST), "cannot be reached"),
(APITimeoutError(request=REQUEST), "did not answer in time"),
(status_error(RateLimitError, 429), "slow down"),
(status_error(InternalServerError, 503), "failed on its side"),
],
)
def test_a_failed_model_call_says_in_plain_words_what_happened_and_keeps_the_detail(error: Exception, words: str) -> None:
text = explain(error)
assert words in text
assert text.endswith(f"({error})")
def test_ctrl_c_at_the_key_prompt_leaves(home: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("ORCAROUTER_API_KEY")
async def run() -> None:
app = AIHawk(home, None)
async with app.run_test(size=(80, 24)) as pilot:
await settle(pilot, app)
assert isinstance(app.screen, TextPrompt)
assert "Enter saves it" in str(app.screen.query_one(".hint", Static).content)
await pilot.press("ctrl+c")
await pilot.pause(0.1)
assert not app.is_running
asyncio.run(run())
def test_leaving_while_a_prompt_is_open_raises_nothing(home: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("ORCAROUTER_API_KEY")
async def run() -> None:
app = AIHawk(home, None)
async with app.run_test(size=(80, 24)) as pilot:
await settle(pilot, app)
assert isinstance(app.screen, TextPrompt)
asyncio.run(run())
def test_at_80_columns_a_message_wraps_whole_and_quit_is_on_the_footer(home: Path, tmp_path: Path) -> None:
scan = tmp_path / "scanned.pdf"
writer = PdfWriter()
writer.add_blank_page(100, 100)
with scan.open("wb") as out:
writer.write(out)
async def run() -> None:
app = scripted_app(home, scan)
async with app.run_test(size=(80, 24)) as pilot:
await settle(pilot, app)
await pilot.pause(0.3) # settle waits for the flow, not for the first paint of the footer
assert "exported with text, or saved as .docx." in " ".join(line.text.strip() for line in app.query_one(RichLog).lines)
rows = ["".join(segment.text for segment in strip) for strip in app.screen._compositor.render_strips()]
assert any("q Quit" in row for row in rows)
asyncio.run(run())
def test_a_posting_whose_words_look_like_markup_is_shown_as_it_is(home: Path) -> None:
async def run() -> None:
app = AIHawk(home)
app.session.store.add(Job("odd", "Co [/y]", "[/x] Engineer", "Milan [bold", "https://a.example/", "https://a.example/", ""))
app.start = lambda: None # type: ignore[method-assign]
async with app.run_test(size=(140, 40)) as pilot:
app.refresh_jobs()
await pilot.pause()
row = app.query_one(DataTable).get_row_at(0)
assert (str(row[2]), str(row[3]), str(row[4])) == ("Co [/y]", "[/x] Engineer", "Milan [bold")
asyncio.run(run())
def test_a_posting_with_no_stored_text_is_scored_on_its_own_pages_text(home: Path, resume: Path, monkeypatch: pytest.MonkeyPatch) -> None:
session = Session(home)
session._client = ScriptedModelForApp() # type: ignore[assignment]
asyncio.run(session.load_resume(resume))
session.store.add(Job("bare", "Firm", "Python Engineer", "Milan", "https://a.example/1", "https://a.example/1", "", "", 1.0))
session.store.add(Job("told", "Firm", "Python Engineer", "Milan", "https://a.example/2", "https://a.example/2", "Stored text.", "", 1.0))
fetched: list[str] = []
def page(job: Job) -> Job:
fetched.append(job.id)
return replace(job, description="The text of its page.")
monkeypatch.setattr(session_module, "with_description", page)
asyncio.run(session.score([session.store.get("bare"), session.store.get("told")], lambda: None, lambda _: None)) # type: ignore[list-item]
assert fetched == ["bare"]
assert session.store.get("bare").job.description == "The text of its page." # type: ignore[union-attr]
asked = " ".join(request["messages"][1]["content"] for request in session._client.requests) # type: ignore[union-attr]
assert "The text of its page." in asked and "Stored text." in asked
session.close()
def test_settings_change_the_search_from_the_screen_and_search_again(home: Path, resume: Path) -> None:
async def run() -> None:
app = scripted_app(home, resume)
async with app.run_test(size=(140, 40)) as pilot:
await settle(pilot, app)
await pilot.press("e")
await wait_for_screen(pilot, Settings)
rows = [key.value for key in app.screen.query_one(DataTable).rows]
app.screen.query_one(DataTable).move_cursor(row=rows.index("exclude"))
await pilot.press("enter")
await wait_for_screen(pilot, TextPrompt)
app.screen.query_one(Input).value = "Senior, Intern"
await pilot.press("enter")
await wait_for_screen(pilot, Settings)
await pilot.press("escape")
await settle(pilot, app)
assert app.session.config.filters.exclude == ("Senior", "Intern")
assert "Searching again" in logged(app)
asyncio.run(run())
def test_a_setting_that_would_not_load_is_refused_and_the_file_stays_as_it_was(home: Path) -> None:
session = Session(home)
before = (home / "aihawk.yaml").read_text(encoding="utf-8")
with pytest.raises(ConfigError, match="max_age_days"):
session.change_setting("max_age_days", "soon")
assert (home / "aihawk.yaml").read_text(encoding="utf-8") == before
with pytest.raises(ConfigError, match="at least one job title"):
session.change_setting("titles", " , ")
session.close()
def test_places_replace_the_ones_read_from_the_cv_and_a_model_clears_back_to_its_default(home: Path, resume: Path) -> None:
session = Session(home)
session._client = ScriptedModelForApp() # type: ignore[assignment]
asyncio.run(session.load_resume(resume))
session.change_setting("places", "London, Leeds")
filters = session.config.filters
assert (filters.locations, filters.cities, filters.countries) == (("London", "Leeds"), ("London", "Leeds"), ())
default = session.config.model
session.change_setting("model", "some/model")
assert session.config.model == "some/model"
session.change_setting("model", "")
assert session.config.model == default
session.close()
def test_the_persons_own_cv_goes_with_the_form_and_a_new_resume_replaces_it(home: Path, resume: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
session = Session(home)
session._client = ScriptedModelForApp() # type: ignore[assignment]
asyncio.run(session.load_resume(resume))
session.store.add(Job("one", "Firm", "Python Engineer", "Milan", "https://jobs.lever.co/a/1", "https://jobs.lever.co/a/1", "A posting.", "", 1.0))
files: list[str] = []
async def fill(_client: Any, _model: str, _job: Any, _cv: str, _answers: str, cv_file: Path, *_rest: Any, **_options: Any) -> Outcome:
files.append(cv_file.read_text(encoding="utf-8"))
return Outcome("blocked", "a puzzle")
monkeypatch.setattr(session_module, "apply_to_job", fill)
asyncio.run(session.apply(session.store.get("one"), lambda _: None)) # type: ignore[arg-type]
newer = tmp_path / "newer.txt"
newer.write_text(CV_TEXT + "A later role at Beta.", encoding="utf-8")
asyncio.run(session.load_resume(newer))
asyncio.run(session.apply(session.store.get("one"), lambda _: None)) # type: ignore[arg-type]
assert files == [CV_TEXT, CV_TEXT + "A later role at Beta."]
session.close()
def test_a_site_with_no_plugin_is_tried_like_any_other(home: Path, resume: Path, monkeypatch: pytest.MonkeyPatch) -> None:
session = Session(home)
session._client = ScriptedModelForApp() # type: ignore[assignment]
asyncio.run(session.load_resume(resume))
url = "https://careers.nobody.example/job/1"
session.store.add(Job("any", "Firm", "Python Engineer", "Milan", url, url, "A posting.", "", 1.0))
asked: list[Any] = []
async def fill(_client: Any, _model: str, job: Any, *_rest: Any, **_options: Any) -> Outcome:
asked.append(job.apply_url)
return Outcome("blocked", "a sign-in")
monkeypatch.setattr(session_module, "apply_to_job", fill)
outcome = asyncio.run(session.apply(session.store.get("any"), lambda _: None)) # type: ignore[arg-type]
assert asked == [url] and outcome.status == "blocked"
session.close()
def test_after_the_resume_the_mail_is_asked_once_and_gmail_is_opened_until_the_inbox_shows(home: Path, resume: Path, monkeypatch: pytest.MonkeyPatch) -> None:
# The question comes right after the resume, in the same voice as the key: the address, then the mailbox open in the browser.
store = Store(home / "data" / "jobs.db")
store.set_answer("mail_provider", "")
store.close()
server = ScriptedServer(page={"url": "https://mail.google.com/mail/u/0/#inbox", "text": "Inbox"})
scripted_server(monkeypatch, server)
async def run() -> None:
app = scripted_app(home, resume)
async with app.run_test(size=(140, 40)) as pilot:
await settle(pilot, app)
assert isinstance(app.screen, TextPrompt) and "email you apply with" in app.screen.question
assert app.screen.query_one(Input).value == "[email protected]"
app.screen.query_one(Input).value = "[email protected]"
await pilot.press("enter")
await wait_for_screen(pilot, SignIn)
assert "Gmail is open" in app.screen.text and "[email protected]" in app.screen.text
assert ("browser_navigate", {"url": "https://mail.google.com/mail/u/0/"}) in server.calls
await pilot.press("enter")
await wait_for_sign_in_to_close(pilot, app)
await settle(pilot, app)
assert companies(app) == ["Bestco", "Lowly", "Noplugin"]
asyncio.run(run())
assert server.calls[-1] == ("browser_close", {})
answers = read_answers(load_config(home))
assert answers["email"] == "[email protected]" and answers["mail_provider"] == "gmail"
def test_the_mail_question_left_with_escape_is_not_asked_again_at_the_next_start(home: Path, resume: Path) -> None:
store = Store(home / "data" / "jobs.db")
store.set_answer("mail_provider", "")
store.close()
async def run() -> None:
app = scripted_app(home, resume)
async with app.run_test(size=(140, 40)) as pilot:
await settle(pilot, app)
assert isinstance(app.screen, TextPrompt) and "email you apply with" in app.screen.question
await pilot.press("escape")
await settle(pilot, app)
assert companies(app) == ["Bestco", "Lowly", "Noplugin"]
assert ("mail", "Your mail", "not set up") in app.setting_rows()
asyncio.run(run())
assert read_answers(load_config(home))["mail_provider"] == "later"