Drive a real headless Chrome for tests without Selenium or a version-matched chromedriver. Two tiny, dependency-free clients that speak the Chrome DevTools Protocol (CDP) straight to the browser over a WebSocket, in the language of your stack:
C:c/cdp.h
, a single header-only library (~410 lines, libc + POSIX sockets only).Java:java/Cdp.java
, a single file (JDKjava.net.http
only, nothing to vendor).
Same idea, same small command set, one demonstration each against the same page.
The image above was produced by mincdp itself: the demo typed into the page, pressed Enter, then captured this driven state with cdp_screenshot (Page.captureScreenshot).
CDP is the wire protocol Puppeteer and Playwright use under the hood. Talking it directly means no chromedriver middleman and no external dependency to vendor and keep version-matched to Chrome. For a fixed set of internal pages, headless, one browser, that is a good trade: the client is small enough to read in a sitting, and if Chrome ever ships a breaking CDP change you fix the one file rather than wait for an upstream driver release.
It is deliberately not a general framework. It knows only the commands a click-and-assert test needs: navigate, evaluate JS, type text, press a key. If you need cross-browser (Firefox/Safari) or a rich interaction API, use Playwright; this earns its place only where minimalism and zero dependencies do.
Needs a headless Chrome/Chromium on PATH
(and curl
, and cc
/ javac
).
make ut # the whole regression suite (see Tests)
make demo-c # build c/demo and run it against page.html
make demo-java # build java/Demo and run it against page.html
make demo # both
make shot # screenshot page.html so you can SEE it
make agent GOAL="..." # let Claude drive the page toward a goal (needs an API key)
Each demo prints a series of assertions and a demo: N passed, 0 failed
line. It
first inspects the page's internals (title, attribute, text, computed style,
geometry), then drives the real input path: type "mincdp works", press Enter, and
assert the page echoes it. Set CDP_DEBUG=1
to see every CDP frame.
make ut
runs the full regression, in two kinds, so a test set has both hands and eyes:
codeut-- "regular" browser-free unit tests of the client's pure helpers (base64, JSON escaping), several examples each. No Chrome needed, so it always runs (tests/codeut.c
).uiut-- the client driving real headless Chrome, in two kinds:page internals + hands-- the demos inspect the rendered DOM (presence, attribute, text, computed style, geometry) throughRuntime.evaluate
, then interact (type, press Enter, assert the DOM changed), then screenshot thedrivenstate over the protocol (cdp_screenshot
->Page.captureScreenshot
) and assert a valid PNG (c/demo.c
,java/Demo.java
).eyes, for agents--tests/shot.sh
is the standalone shell version: screenshots a fresh page load with Chrome's--screenshot
, no client code needed. It complements the demos' protocol screenshot (which captures a driven state). Either PNG is there to be looked at: open it, or have an agent Read it.
Each layer reports PASS
/ FAIL
/ SKIP
(a missing toolchain SKIPs, exit 77); the suite fails only if a non-skipped layer fails:
mincdp regression:
codeut (units) PASS
uiut hands: C PASS
uiut hands: Java PASS
uiut eyes: shot PASS
The demos double as the interaction tests; make demo-c
/ demo-java
run them on their own.
You start Chrome; the client attaches. demo.sh
does that wiring (launch a
headless Chrome with --remote-debugging-port
, run the demo, tear down), and it
invokes both demos with the same contract, so one launcher and one page.html
serve both languages:
PROG... 127.0.0.1 <chrome-port> file://<abs>/page.html
The same two primitives that make a smoke test, a screenshot (eyes) and the
input calls (hands), are the entire surface an LLM needs to drive a browser.
c/agent.c
is that loop and nothing else. See, think, act, repeated.
See.cdp_screenshot
captures the live page, whatever state prior actions drove it to.Think. The PNG plus a one-line goal go to the Claude API overcurl
(the one hop raw sockets can't do here is the TLS toapi.anthropic.com
). The model replies with exactly one action.Act. The client replays that one action against the page, then loops.
The model gets a screenshot and answers with a single line, one of:
| Reply | Hand |
|---|---|
NAV <url> |
|
cdp_navigate |
|
TYPE <text> |
|
cdp_insert_text |
|
KEY <name> |
|
cdp_key (e.g. KEY Enter ) |
|
JS <expr> |
|
cdp_eval_bool , a `querySelector(...).click() |
|
DONE <note> |
|
| stop |
Same substring trick as the client: the reply is one short line, so pulling the
action out of the response is a single "text":"..."
find, not a JSON parser.
export ANTHROPIC_API_KEY=sk-ant-...
make agent GOAL="type 'mincdp works' into the box and press Enter"
A run against page.html
looks like:
step 1: JS document.getElementById('q').focus()||true
step 2: TYPE mincdp works
step 3: KEY Enter
step 4: DONE the box shows "echo: mincdp works"
Each step is a full loop: the model looked at a fresh screenshot, chose the next
keystroke, and mincdp replayed it, closing on DONE
when the screenshot showed the goal met.
It is deliberately a sketch: one action per turn, substring parsing, no retries,
thinking off for speed. That minimalism is the point. It is small enough to read
end to end before you let a model drive a browser. Like the demos, it SKIPs
(exit 77) when Chrome, curl
, or ANTHROPIC_API_KEY
is missing. See
c/agent.c
and agent.sh
.
Both clients expose the same shape (C names shown; Java is the camelCase twin):
| Call | Does |
|---|---|
cdp_open(host, port) / Cdp.attach |
|
| attach to the first page target of a running Chrome | |
cdp_navigate(url) |
|
Page.navigate (does not wait for load) |
|
cdp_eval_bool(js, &out) / evalBool |
|
Runtime.evaluate a boolean expression |
|
cdp_wait_bool(js, ms) / waitBool |
|
| poll a boolean expression until true or timeout | |
cdp_insert_text(text) / insertText |
|
Input.insertText into the focused element |
|
cdp_key("Enter") / key |
|
Input.dispatchKeyEvent keyDown+keyUp |
|
cdp_screenshot(path) / screenshot |
|
Page.captureScreenshot -> base64 -> write a PNG |
|
cdp_close() / close |
|
| detach |
Responses are matched by id and read with targeted substring checks, not a JSON
parser. That is the trick that keeps both clients dependency-free: every command
returns a boolean, a short ack, or one flat field (the screenshot's base64), so
looking for "value":true
/ exceptionDetails
/ "data":"..."
is enough. A command that returned deeply nested JSON would need a real parser; none does.
c/cdp.h
is a single-header library. In exactly one translation unit:
#define CDP_IMPLEMENTATION
#include "cdp.h"
and just #include "cdp.h"
elsewhere for the declarations. See c/demo.c
.
Same category as a Selenium test (out-of-process automation: your code in one process, a real browser in another, talking over a wire protocol), minus the chromedriver middleman and the external dependency. Closer in spirit to a minimal, hand-rolled Puppeteer.
| Selenium | mincdp |
|---|---|
WebDriver driver |
|
cdp handle |
|
driver.get(url) |
|
cdp_navigate(url) |
|
findElement / waits |
|
cdp_wait_bool(sel-exists) |
|
((JavascriptExecutor)driver).executeScript |
|
cdp_eval_bool(js) |
|
element.sendKeys |
|
cdp_insert_text |
|
| key press | cdp_key("Enter") |
What differs:
Selenium talks the WebDriver protocol to a separatechromedriver
binary that then drives Chrome: three processes, and you must keep chromedriver's version matched to Chrome's. It is a jar plus a versioned native binary to vendor and update.mincdp talks CDP directly to Chrome over a WebSocket, no middleman. Each client is one file using only its language's standard library.
The trade, honestly: Selenium and Playwright give a big, polished, cross-browser API (rich waits, action chains, a grid). mincdp gives the handful of commands a headless smoke test needs, in code you can read end to end. Pick accordingly.
page.html self-contained demo target (no server, no network)
demo.sh launch Chrome, run a demo, tear down (shared by both)
Makefile make ut / demo-c / demo-java / demo / shot / clean
c/cdp.h the C client (single-header library)
c/agent.c the agent loop: screenshot, Claude, replay one action
agent.sh launch Chrome, run the agent toward a GOAL, tear down
c/demo.c the C demonstration = the uiut interaction test (hands)
java/Cdp.java the Java client (single file)
java/Demo.java the Java demonstration = the uiut interaction test (hands)
tests/run.sh the regression runner (make ut): codeut + uiut, PASS/FAIL/SKIP
tests/codeut.c browser-free unit tests of the client's pure helpers
tests/shot.sh the eyes: screenshot page.html to a PNG (Chrome --screenshot)
legacy/
holds the 2005 ancestor of this project: an Xvfb-based screenshot
capture proof of concept, from before browsers had a headless mode or a remote
protocol. It is where mincdp started. See legacy/README.md
.
The dated record of how the "eyes" came about (the origin dialog, the timeline, and the attribution ask) is in legacy/PROVENANCE.md.
MIT. See LICENSE
.
All product names, logos, and brands are property of their respective owners. Chrome is a trademark of Google; Selenium, Puppeteer, and Playwright are the names of their respective projects. They are used here only for identification and comparison, and no affiliation or endorsement is implied.