Writing ·

Testing an agent that must never submit

How Preflight's test suite proves its dry-run guard works, with a fake LLM, real Chromium and requests that never arrive.

“Never submits” has to be proven

A dry run promises something narrow and important: the agent will fill the whole application and stop before the final submit. That promise is easy to state and easy to get wrong. A click could land on the wrong button, a page’s own script could send the form, or a guard could quietly stop working after a refactor. If nobody checks, “never submits” is a hope.

So the tests treat it as a property to prove. They run the guard in headless Chromium against local pages and check, by the requests the server receives, that the final submit never arrives while the allowed steps do. The evidence is not a log line saying the guard thinks it blocked something. It is the absence of the request a submission would have made.

Offline by design

The suite has 805 tests and runs in under 30 seconds, with no network, no API key and no real browser profile. A fake OpenAI-compatible server stands in for the model, answering each prompt with fixed text built from the fictional applicant Jordan Rivera. It records every request, and a test can replace its reply to get a different answer.

The test bootstrap keeps all of that away from a real install. Every test runs against a throwaway data folder, never ~/.preflight, and the bootstrap deletes any LLM_, ANTHROPIC_, APPLY_ or CLAUDE_CODE_ variable it inherited, along with the provider keys, so a developer’s shell cannot leak a provider, a key or a mode into a test run. A guard that passes because the machine happened to have the right API key is not a test of the guard.

Proving a request never arrives

The guard tests serve their fixture pages from a local server that records every non-HTML request: method, path and body. Each scenario runs twice. The first pass uses a control context with no guard, which shows the page really would submit. The second uses a context built from the rendered dry_run_guard.js, and the same click must reach the server in the control and must not reach it with the guard.

The plain form case makes the shape clear. In the control context, Next posts to /next, Save & Continue posts to /save-continue, and the final Submit posts to /apply. The test clears the server’s record, opens the guarded context, clicks the final Submit, and waits. Then comes the assertion that matters:

# tests/test_dry_run_guard.py
    with _page(chromium, guard_script, guard_server, page_name, guarded=True) as page:
        page.click(button)
        page.wait_for_timeout(250)
        assert not _received(guard_server, "POST", "/apply")

The guard has recorded a final block with the right label and state, and the banner reads “PREFLIGHT DRY RUN: final Submit blocked (Submit)”. Then the test clicks Next and Save & Continue and checks that both requests do arrive. Blocking everything would not pass this test either. The control half is what makes the guarded half meaningful: the same page, the same click, and only the guard changes the outcome.

The suite applies the same pattern to the other ways a form submits. A page whose script calls form.submit(), a bare requestSubmit(), and a requestSubmit() aimed at a submit control are all run in an unguarded context first, where they post, and then with the guard, where they do not. It also covers a Workday-style final step, a non-final step whose Save and Continue must still reach the server, a generic review page where Next is itself the final submit, account sign-in and verification forms, and a consent modal whose Confirm passes while the application submission after it is held.

Testing the hook

tests/test_dry_run_hook.py covers the other side of the same promise. It checks that a helper file on the allow-list may run, that a browser_run_code_unsafe call carrying any inline code is refused, and that an unknown or pathed filename is refused. It also checks that the window.__apArgs literal hand-off is allowed, that browser_evaluate code which clicks or submits is refused, and that a malformed payload fails closed with exit 2. The tests exercise both the decision function and the hook’s real subprocess entry point.

What CI taught me

A CI run failed, and the reason was not the code under test. GitHub Actions asks Rich for color, so the CLI help text carried color codes that the local run never showed, and the help test missed the command names it was looking for on every CI runner. The fix strips the escape sequences before matching, which is the kind of bug that only shows up when the test runs somewhere other than the machine it was written on. CI now runs the suite on Linux from Python 3.11 to 3.13, and on macOS.

What the tests don’t cover

Live submissions are left out on purpose. Nothing in CI and nothing in the test suite submits an application. The tests prove that the guard refuses the final submit, and they prove it without sending an application anywhere, which is the only way I want that property tested.