@auphansoftware/cdp-cli
v2.3.0
Published
CLI for Chrome DevTools Protocol - optimized for LLM agents with NDJSON output
Maintainers
Readme
Chrome DevTools CLI
Command-line interface for Chrome DevTools Protocol (CDP), optimized for LLM agents with NDJSON output format.
Overview
cdp-cli provides CLI access to all Chrome DevTools Protocol features, making it easy to automate browser interactions, debug web applications, and inspect network traffic - all from the command line with grep/tail-friendly output.
Browser evidence workflow
workflow observe PAGE --task 'goal' returns deterministic compact state with
canonical expansion references. workflow act PAGE --task 'goal' --source SOURCE_ID
--action click --selector '#save' --wait-for '#result' returns action delivery,
fresh state, the latest diff and available diagnostic errors in one response.
No model server is required. workflow-mcp exposes observe/act/expand/screenshot
tools for inherited owned browser sessions and returns screenshots as image
blocks, avoiding a separate file Read. See workflow deployment and usage.
Page state captures and diffs (experimental)
Use a bounded page-state diff when the question is whether an action changed visible text or control state. The last NDJSON line from state click, state diff, and state expect is the result envelope.
cdp-cli state capture PAGE --name before
cdp-cli click '#save' PAGE
cdp-cli state capture PAGE --name after
cdp-cli state diff before after PAGE
# One call for capture, click and diff:
cdp-cli state click '#save' PAGEstate diff reports changed:true, changed:false, or changed:null when coverage is incomplete. It includes added and removed controls or text regions, field transitions, and coverage warnings. Captures walk same-origin iframes and open shadow roots by default. Use --frame '#frame-id' to limit a capture and --ignore '.clock' to exclude a known volatile region. The default capture takes a second reading after 1.2 seconds to detect loading or changing content. state click waits for network idle by default; use --no-wait-for-idle only when the action is known to be synchronous. --stability-ms=0 is intended for already settled deterministic fixtures. A delayed update that begins after the idle window still needs an explicit wait or later capture.
Expectations live in a JSON file:
{
"mustChange": [{ "key": "top|id:status", "field": "text", "from": "Ready", "to": "Saved" }],
"mustNotChange": [{ "key": "top|id:total", "field": "text" }]
}Run cdp-cli state expect before after PAGE --spec expectations.json. Its value.outcome is PASSED, FAILED, or UNKNOWN. Evaluation returns exit code zero even for a failed assertion so Jarvis can classify the final JSON line; add --exit-on-fail in a shell gate. A MUST-NOT-CHANGE key absent from both captures or backed only by a weak position-based identity is UNKNOWN. Literal from and to strings for field:"value" are compared through the local HMAC; entered values are stored as length and digest. Captures are scoped to the browser endpoint, session and page target under ~/.cdp-cli/state; the newest 200 captures per target are kept by default. Set CDP_STATE_MAX_CAPTURES=0 for unlimited retention. state list PAGE and state rm REF PAGE manage them.
state click '#save' PAGE --spec expectations.json --exit-on-fail checks the expectation file before clicking and returns one final verdict line. Use a stable ID, data-testid, or row key for assertions. Captures with different frame, ignore, or element-limit options cannot be compared. Incomplete coverage, unstable content, unreachable frames, or ambiguous keys yield UNKNOWN instead of a false no-op. Raw visible labels and text are persisted in capture files; editable and form values are masked, but capture files should still be treated as sensitive artifacts.
This prototype does not yet include console or network activity in the diff. CSS boxes are included only with state diff ... --layout; color, borders, imagery, closed shadow roots and other visual effects still require a targeted screenshot. A diff proves a visible transition at capture time, not persistence in the database. Use the existing browser, console, network and database checks for those questions.
Record successful actions and replay them
Set CDP_RECORD_FILE to a new NDJSON file while exploring a journey. Successful navigate, click, fill, select, and press-key commands append a step. state click is recorded only with --spec ... --exit-on-fail and a passing expectation. Failed actions do not enter the journal. Each step replaces the page ID with {{page}}, so it can run against another page in the same kind of test environment.
$env:CDP_RECORD_FILE = 'C:/qa/menu-journey.ndjson'
cdp-cli click '#menu' PAGE --frame '#page-engine2-iframe' --wait-for '#menu-form'
cdp-cli fill '#name' 'Lunch' PAGE --frame '#page-engine2-iframe' --expect-value
Remove-Item Env:CDP_RECORD_FILE
# Fill values become value2, not literal text in the journal:
# params.json: { "value2": "Lunch" }
cdp-cli record replay C:/qa/menu-journey.ndjson PAGE --params C:/qa/params.jsonReplay runs the saved commands in order, stops on the first failure, and prints one compact verdict. It checks click/frame delivery and requires recorded state expectations to pass again. A recorded --frame must be a stable CSS selector; frame indexes are rejected. Frame selection now resolves the iframe's actual DOM owner, so two frames with the same URL cannot be confused. Same-origin frame commands are supported; use the existing target-frame and target-* commands for out-of-process frames. Review action files before using them in another environment: command success proves that the browser action ran, while state click --spec proves the specified state transition. Visual defects still need screenshots.
In Jarvis browser-qa-run, one step can call {"cdp":["record","replay","C:/qa/menu-journey.ndjson","{{page}}","--params","C:/qa/params.json"],"parse":"json","expect":{"path":"outcome","equals":"PASSED"}}. Keep the QA row's business assertion and targeted screenshot separate from replay. The journal is append-only and limited to 200 actions; use one file per journey. Review it before reuse because selectors, labels, and literal navigation URLs may still contain page text.
Distribution note This scoped build (
@auphansoftware/cdp-cli) is published for Auphan Software internal use, remains under the MIT license, and bundles the upstream work originally authored by @myers at github.com/myers/cdp-cli.
Upgrading to 1.12.0 Actions can now wait on a JavaScript predicate or a specific response, and
click/fillverify their postconditions instead of treating a dispatched CDP event as a completed interaction. Start the daemon before using buffered network history ordiagnose; usenetwork-detailonly with permission to inspect the selected response body.The 1.8.0 breaking change remains in effect:
--wait-forand--wait-for-textnow default to the document named by--frame, instead of always checking the top document. This affects any script that passes--frametogether with either flag. Pass--wait-for-frame 0to restore the old behaviour. See CHANGELOG.md.On Windows, also run
npm run install:exeafter upgrading — a stalecdp-cli.exeon PATH shadows the npm build for cmd.exe, PHPexec(), batch and CI. See Windows: which cdp-cli is actually running.
Maintainers: the Git release and the installer executable are separate release steps. Follow RELEASING.md to publish and independently verify the public S3 artifact.
Installation
# Local installation
cd chrome-devtools-cli
npm install
npm run build
# Global installation
npm install -g @auphansoftware/cdp-cli
# Verify the CLI is available, regardless of platform shims (.cmd/.ps1)
cdp-cli --helpPrerequisites
- Node.js 18 or newer
- The CLI bundles a
fetchpolyfill for older 18.x builds.
- The CLI bundles a
- Google Chrome started with remote debugging enabled:
# macOS
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
--remote-debugging-port=9222
# Linux
google-chrome --remote-debugging-port=9222
# Windows
chrome.exe --remote-debugging-port=9222Quick Start
# Start the daemon (enables automatic logging for all new pages)
cdp-cli daemon start
# Create a new page (auto-registers with daemon for logging)
cdp-cli new-page "https://example.com"
# Take a screenshot at half the CSS viewport size, independent of desktop DPR
cdp-cli screenshot "example" --output screenshot.png --viewport-scale 0.5
# Query the last 20 console messages
cdp-cli logs console "example" --last 20
# Query network requests
cdp-cli logs network "example" --last 10
# Ask only after the click's request really completes
cdp-cli click "#save" "example" --wait-for-response "/api/order" --wait-for-status 201
# Evaluate JavaScript
cdp-cli eval "document.title" "example"Output Format: NDJSON
All list commands output newline-delimited JSON (NDJSON) - one complete JSON object per line. This format is:
- LLM-friendly: Easy to parse programmatically
- Grep-compatible: Filter with standard Unix tools
- Streamable: Handle large datasets incrementally
Example NDJSON Output
$ cdp-cli list-pages
{"id":"A1B2C3","title":"GitHub","url":"https://github.com","type":"page"}
{"id":"D4E5F6","title":"Google","url":"https://google.com","type":"page"}
$ cdp-cli logs console "example" --last 2
{"id":41,"type":"log","timestamp":1698234567890,"text":"Page loaded","source":"console-api"}
{"id":42,"type":"error","timestamp":1698234568123,"text":"TypeError: Cannot read...","source":"exception"}
$ cdp-cli logs network "example" --filter xhr
{"id":"123.1","method":"POST","url":"https://api.example.com/data","type":"XHR","timestamp":65897097}Commands
Page Management
list-pages - List all open browser pages
cdp-cli list-pagesnew-page - Create a new page/tab
cdp-cli new-page "https://example.com"
cdp-cli new-page # Empty page
# Query strings and hash fragments are preserved in full — quote the URL so the
# shell does not split it on '&'.
cdp-cli new-page "http://127.0.0.1/app/?scenario=admin&cb=1#s:login.php?lang_id=1"navigate - Navigate page (URL, back, forward, reload)
cdp-cli navigate "https://github.com" "example"
cdp-cli navigate back "example"
cdp-cli navigate forward "example"
cdp-cli navigate reload "example"
# Wait for content after navigation
cdp-cli navigate "https://example.com" "example" --wait-for "#content"
cdp-cli navigate "https://example.com" "example" --wait-for-text "Welcome"
cdp-cli navigate "https://example.com" "example" --wait-for-idle
# Wait for content inside an iframe
cdp-cli navigate "https://example.com" "example" --wait-for "#form" --wait-for-frame "#myframe"
# Wait for the document to actually be replaced
cdp-cli navigate "https://example.com" "example" --wait-for-navigationOptions:
--wait-for <selector>: Wait for CSS selector to appear after navigation--wait-for-text <text>: Wait for text to appear in page body--wait-for-idle: Wait for network idle and document ready--wait-for-frame <spec>: Target iframe for wait checks (by selector or index)--wait-for-navigation: Wait for a real document replacement (see below)--timeout <ms>: Timeout for wait operations (default: 10000)
Waiting for a navigation
--wait-for-navigation is available on navigate, click, fill, select and
press-key. It resolves only when the watched frame commits a new document and
that document finishes loading, tracked by CDP Page.frameNavigated plus a
completion event, and keyed on the frame's loaderId. The watched frame is the
main frame unless --frame/--wait-for-frame names another — see
Waiting on an iframe below.
Use it for classic post-and-refresh pages, where a form POST re-renders the whole
document. --wait-for-text is the wrong tool there: every label you might wait
for is already on the outgoing document, so the wait returns immediately and
the assertions that follow run against the old DOM — a silent false result.
# Correct: returns only after the POST response has loaded
cdp-cli click "#save" "example" --wait-for-navigation
# Combine them: navigation resolves first, so the text is matched on the NEW document
cdp-cli click "#save" "example" --wait-for-navigation --wait-for-text "Saved"Notes:
- Same-document changes (hash routes,
history.pushState) keep theloaderIdand deliberately do not satisfy this wait. Use--wait-for/--wait-for-textfor SPA route changes. - On timeout the command fails with a non-zero exit and states whether the document never committed or committed but never finished loading.
Waiting on an iframe
A form POST inside an iframe replaces that frame's document; the main frame
never navigates. A main-frame wait would sit there until it timed out, so name
the frame to watch. The frame comes from --frame by default, and
--wait-for-frame overrides it:
# Submit inside an iframe — the wait follows --frame automatically
cdp-cli click "input[value='Add']" "example" --frame "#page-iframe" --wait-for-navigation
# Click in the TOP document, but the iframe is what navigates
# (common when a shell re-parents a page's buttons into its own nav bar)
cdp-cli click ".nav-save-btn" "example" --wait-for-navigation --wait-for-frame "#page-iframe"Main-frame waits still ignore subframe loads entirely, so an unrelated iframe
(an ad, a printer-queue frame) can never satisfy one. Subframe completion is
detected with Page.frameStoppedLoading and Page.lifecycleEvent, because
Page.loadEventFired only ever fires for the main frame.
--wait-for-frame also scopes --wait-for and --wait-for-text, so those are
checked inside the same document you were driving.
Changed in 1.8.0:
--wait-forand--wait-for-textnow default to the frame given by--frameinstead of always checking the top document. If you were relying on checking the top document while acting inside a frame, pass--wait-for-frame 0to target the top frame explicitly.
Expression and response waits
All action commands that already support post-action waits (navigate,
click, fill, select, and press-key) also support these precise
conditions:
# Poll inside the selected wait frame until the expression is truthy.
cdp-cli click "#save" PAGE --wait-for-expression "window.orderSaved === true"
# Source an expression without fragile shell quoting.
cdp-cli click "#save" PAGE --wait-for-expression-file .\waits\order-saved.js
Get-Content .\waits\order-saved.js | cdp-cli click "#save" PAGE --wait-for-expression-stdin
# Observe the request before the click, then resolve only on the matching response.
cdp-cli click "#save" PAGE --wait-for-response "/api/orders" --wait-for-status 201
cdp-cli click "#save" PAGE --wait-for-response "/api/orders" --wait-for-body-text '"saved":true'--wait-for-expression accepts synchronous or async JavaScript. Use exactly
one of the inline, --wait-for-expression-file, or
--wait-for-expression-stdin forms. --wait-for-response matches a
literal URL substring, is armed before the triggering action, and can be
combined with exact --wait-for-status and literal --wait-for-body-text.
The body predicate reads at most 64 KiB and never prints response content.
--wait-for-status and --wait-for-body-text are invalid without
--wait-for-response.
--wait-for-idle is also armed before the action. It tracks real request IDs,
redirects, and failed loads, which avoids the old race where fast network work
finished before a post-action wait had attached.
Use the same predicates without triggering an action through wait PAGE:
cdp-cli wait PAGE --expression "window.checkoutReady === true" --wait-for-frame "#content"
cdp-cli wait PAGE --wait-for-response "/api/status" --wait-for-status 200
cdp-cli wait PAGE --wait-for ".complete" --timeout 30000The standalone command uses --expression (or --expression-file /
--expression-stdin) and retains the existing --wait-for* spellings for
selector, text, idle, frame, and response checks. Expression timeouts include
the last bounded value or exception to make a failed predicate diagnosable.
close-page - Close a page
cdp-cli close-page "example"
cdp-cli close-page A1B2C3resize-window - Resize the Chrome window for a page
cdp-cli resize-window "example" 1024 768
cdp-cli resize-window "example" 0 0 --state maximized
cdp-cli resize-window "example" 0 0 --state fullscreenOptional flags:
--state: Window state:normal,maximized,minimized,fullscreen
Daemon (Recommended for LLM Agents)
The daemon runs in the background and automatically captures console and network logs for all pages. Logs are stored in a circular buffer (last 500 entries per page) and can be queried at any time.
daemon start - Start the background daemon
cdp-cli daemon start # Start with defaults
cdp-cli daemon start --buffer-size 1000 # Custom buffer sizedaemon stop - Stop the daemon
cdp-cli daemon stopdaemon status - Check daemon status and list active sessions
cdp-cli daemon statusFeatures:
- Auto-discovery: Automatically detects and registers ALL pages (including popups from
window.open) - Persistent: Logs survive across CLI invocations (until daemon stops)
- Chrome restart handling: Automatically reconnects and re-registers pages
- Idempotent: Safe to call
daemon startmultiple times
status - Check the CLI build, daemon, and Chrome connection status
cdp-cli status
{"cli":{"version":"1.10.0","build":"2026-08-12T18:25:59Z","runtime":"exe"},"daemon":{"running":true,"sessions":3},"chrome":{"running":true,"version":"Chrome/150.0.7871.115","pages":3}}cli(added in 1.10.0): which build answered —versionis a bare semver safe to hand to any comparator,buildis when it was produced, andruntimeisnpm(the shell/.cmdshim) orexe(the standalone binary). A script that gates on a version should readcli.versionhere rather than parsing--version, whose build suffix breaks PHP'sversion_compare. See Windows: which cdp-cli is actually running.daemon: running/stopped and session count.chrome: running/stopped, version, page count.
ready - Launch Chrome + start daemon + return pages (all-in-one)
cdp-cli ready # Uses defaults
cdp-cli ready --port 9333 # Custom CDP port
cdp-cli ready --profile ~/my-chrome-profile # Custom profile dir
CDP_DAEMON_URL=http://127.0.0.1:9334 cdp-cli ready --port 9333 --cdp-url http://127.0.0.1:9333 --headlessThis command:
- Launches Chrome with remote debugging if not already running
- Starts the daemon if not already running
- Returns list of open pages
Page Health and Diagnostics
page-health - Inspect whether a page is visible, focused, sized, and able to receive browser input, without changing which page is active:
cdp-cli page-health "example"activate-page - Explicitly bring a page to the foreground when a workflow really needs it. Normal inspection and actions do not activate pages as a side effect:
cdp-cli activate-page "example"diagnose - Collect a bounded, redacted failure bundle:
cdp-cli diagnose "example"
cdp-cli diagnose "example" --output-dir .\artifacts\checkout-failureThe emitted bundle contains redacted current/frame URLs, dialog and page-health
state, a compact DOM summary, and recent console/network failures when the
daemon is running. --output-dir creates the directory if needed and adds a
manifest.json plus screenshot.png. It refuses to overwrite either artifact
if it already exists. URL query values and dialog text are redacted; as with
any screenshot or DOM-derived diagnostic, inspect the bundle before sharing it.
Named Workspace Sessions
Named sessions make ownership explicit when several agents share one Chrome.
Creating a session makes one initial page in an isolated BrowserContext by
default:
cdp-cli session create checkout --url https://example.test/checkout
cdp-cli session ensure checkout --url https://example.test/checkout # idempotent; returns stable pageId
cdp-cli session list
# Work only with that session's exact owned target IDs.
cdp-cli list-pages --session checkout
cdp-cli new-page https://example.test/receipt --session checkout
# Bring an existing page under a session only by its exact CDP target ID.
cdp-cli session adopt checkout EXACT_TARGET_ID
# This is destructive: it closes the isolated context and its pages.
cdp-cli session remove checkout --force
# Dispose and recreate only this named isolated session.
cdp-cli session reset checkout --forceWithin --session NAME, page and target references must be exact IDs owned by
that session; title/URL matching is deliberately disabled and cross-owner
access fails with a structured PAGE_NOT_OWNED error. Use session create
NAME --shared only for compatibility with Chrome's default shared context; it
is never disposed by a session. Session state persists IDs and session metadata
only—never page titles, URLs, logs, credentials, or storage—and fails closed if
it belongs to another browser instance. BrowserContext isolation prevents
accidental interference; it is not a security boundary.
session ensure is the automation-friendly entry point: it atomically creates
the session and one background page when absent, returns the existing primary
page on later calls, and recreates a primary page only if every owned page was
closed. CDP_URL, CDP_DAEMON_URL, and CDP_SESSION can supply the defaults
for unattended callers. Use a distinct daemon URL for each distinct Chrome CDP
endpoint (for example 9333/9334 for a headless automation instance).
The stock daemon on 9223 is assumed only for the stock Chrome endpoint on 9222.
Any other --cdp-url / CDP_URL requires an explicit CDP_DAEMON_URL;
otherwise daemon-backed commands fail immediately with DAEMON_URL_REQUIRED
rather than silently asking a daemon that serves a different Chrome. A daemon
or session store that does not know the named session answers
SESSION_NOT_FOUND; PAGE_NOT_OWNED (with details.actualOwner) means the
session exists but another session owns the page. Wrapped command failures
such as EVAL_FAILED keep that underlying code under details.cause.code.
session reset NAME --force closes that named isolated context and all of its
pages, then creates exactly one fresh about:blank page. It never wipes other
session metadata. A shared compatibility session cannot be reset because its
pages share Chrome's default context. If replacement creation fails after the
old context is gone, the command reports SESSION_RESET_INCOMPLETE.
Session-store writes fail closed. If a process crashes during the tiny lock
mutation critical section, the error reports an exact *.lock.guard path.
First confirm that no cdp-cli process is using that endpoint's session store,
then remove only that reported guard file and retry. session reset NAME --force
does not bypass or delete a guard, because doing so automatically could admit
two writers.
For a stale persisted store whose Chrome instance no longer exists, stop the dedicated CDP Chrome and daemon first, then run the explicit metadata-only recovery command:
cdp-cli session metadata-reset --force --metadata-onlyLog Queries
Query console and network logs from the daemon's buffer. Requires daemon to be running.
logs console - Get console messages
cdp-cli logs console "example" # Last 20 messages (default)
cdp-cli logs console "example" --last 50 # Last 50 messages
cdp-cli logs console "example" --last 0 # All buffered messages
cdp-cli logs console "example" --filter error # Only errorslogs network - Get network requests
cdp-cli logs network "example" # Last 20 requests (default)
cdp-cli logs network "example" --last 50 # Last 50 requests
cdp-cli logs network "example" --last 0 # All buffered requests
cdp-cli logs network "example" --filter xhr # Only XHR requests
# Apply precise filters before taking the last N matching requests
cdp-cli logs network "example" --url /api/orders --method POST --status 500
cdp-cli logs network "example" --failed --since 1787059200000Network records retain their response status, headers, redirects, terminal
completion, and failure details. For a full retained lifecycle, including
redacted request/response headers:
cdp-cli network-detail REQUEST_ID "example"
# Response bodies are opt-in and bounded; only use where the data is allowed.
cdp-cli network-detail REQUEST_ID "example" --body --max-body-bytes 32768--url is a literal substring; --method is matched case-insensitively;
--status, --failed, and --since filter the recorded lifecycle. A response
body is never included unless --body is explicit, and sensitive standard
headers (for example Authorization, Cookie, and X-Api-Key) are redacted.
logs clear - Clear logs for a page
cdp-cli logs clear "example"logs-detail - Get full console message with stack trace
cdp-cli logs-detail 5 "example" # Get message ID 5 with stack traceDebugging
list-console - Stream console messages (real-time, bounded)
Prefer
logs console: the daemon already buffers messages, so a query returns immediately instead of occupying a terminal. Uselist-consoleonly when you need events as they happen.
# Collect a bounded 30-second window and exit (default)
cdp-cli list-console "example"
# Collect for 2 seconds and quit
cdp-cli list-console "example" --duration 2
# Stream until interrupted - explicit opt-in, never the default
cdp-cli list-console "example" --follow
# Filter by type
cdp-cli list-console "example" --type errorA bare invocation is always bounded: it stops after 30 seconds and prints a
final {"event":"monitor-stopped","reason":"duration",...} line. --duration 0
is rejected; unbounded streaming requires --follow. --duration may not be
combined with --follow, and the bounded maximum is 3600 seconds.
snapshot - Get page content snapshot
Owned workflow agents can use workflow snapshot / MCP snapshot for this
concise actionable discovery, with raw field values excluded, explicit alignment
and omissions. Routine MCP images support screenshotViewportScale:0.5, measured
against the CSS viewport rather than device pixels. Coordinate workflow clicks
accept x/y in the screenshot delivered with the exact current source; the tool
maps pixels internally and refuses changed overlapping surfaces, source semantics,
layout, scroll/viewport or missing mapping. It rechecks after pointer movement.
Refresh after layout changes. Canvas/video and cross-origin-frame guards are
unsupported. Event delivery still requires independent effect verification.
cdp-cli workflow screenshot PAGE --task 'Inspect next action' --screenshot-viewport-scale 0.5
cdp-cli workflow act PAGE --task 'Click visible button' --source SOURCE --action click --x 300 --y 150
cdp-cli workflow snapshot PAGE --task 'Find exact field selector'
# Raw CLI points use CSS viewport coordinates directly; they are not screenshot-bound.
cdp-cli click PAGE --x 600 --y 300# Actionable elements for click/fill (default)
cdp-cli snapshot "example"
# Plain text content
cdp-cli snapshot "example" --format text
# Target an iframe
cdp-cli snapshot "example" --frame "#myframe"
cdp-cli snapshot "example" --frame 1 # First iframe by indexOptions:
--format <ax|text>: Output format (default: ax)--frame <spec>: Target iframe by selector or index
The default ax format returns one line per actionable element:
[button] "Submit" → #form > button
[select] value="1" options=["Yes","No"] → select[name="confirm"]
[input:text] name=email placeholder="Enter email" → #email
[link] "Sign up" → #nav > a:nth-of-type(2)Each line shows: [role] "label" state → selector
eval - Evaluate JavaScript expression
cdp-cli eval "document.title" "example"
cdp-cli eval "window.location.href" "example"
cdp-cli eval "Array.from(document.querySelectorAll('h1')).map(h => h.textContent)" "example"
# Async evaluation (wraps in async IIFE)
cdp-cli eval --async "await fetch('/api').then(r => r.json())" "example"
# File-based evaluation (avoids shell escaping issues)
cdp-cli eval _ "example" --file script.js
cdp-cli eval _ "example" --file script.js --async # Combined
# Evaluate inside an iframe
cdp-cli eval "document.querySelector('select')?.id" "example" --frame "#myframe"
# Raise the per-round-trip cap for a long in-page wait
cdp-cli eval --async "await new Promise(r => setTimeout(r, 15000)); return 1" "example" --timeout 30000Optional flags:
--async, -a: Wrap code in async IIFE for await support--file, -f: Read JavaScript from file (expression argument ignored)--stdin: Read JavaScript from stdin instead of expression argument--frame: Target iframe by selector or index--timeout: Max milliseconds for each CDP round trip. The default cap is 10000ms through the daemon and 30000ms on a direct connection, and it covers the whole round trip, not just the in-page code - so an in-page wait close to the cap needs this flag. Must be a whole number from 1 to 600000.
screenshot - Take a screenshot
# Save to file
cdp-cli screenshot "example" --output screenshot.jpg
# Different formats
cdp-cli screenshot "example" --output screenshot.png --format png
# Downscale before saving (50% size). Scaling re-encodes as PNG, so the
# format defaults to png and an explicit --format jpeg/webp is rejected.
cdp-cli screenshot "example" --output screenshot.png --scale 0.5
# Clip to one element (scrolled into view first, 10px padding)
cdp-cli screenshot "example" --selector "#invoice-total" --output el.png --format png
# Output base64 (NDJSON)
cdp-cli screenshot "example"Optional flags:
--output, -o: Save to file instead of emitting base64--format, -f: Choosejpeg,png, orwebp--quality, -q: JPEG quality (0-100)--scale, -s: Downscale width and height by the factor (0 < scale <= 1)
Prefer --output in scripted and agent use: without it the image is emitted as
base64 on stdout, which for a full-page capture is hundreds of KB and will flood
a calling process or transcript. With --output the command prints only the
resolved absolute path, format, byte size, and pixel dimensions:
{"success":true,"message":"Screenshot saved","data":{"file":"Q:\\web\\shots\\page.png","format":"png","size":47853,"width":2560,"height":1330}}The format is inferred from the output file extension (.png, .jpg/.jpeg,
.webp) unless --format says otherwise. Paths are resolved natively, so on
Windows use a drive-qualified or project-relative path (shots/page.png,
Q:/web/app/dev/tests/shots/page.png) — there is no /tmp on Windows. The
parent directory must already exist.
dialog - Check for and handle JavaScript dialogs (alert/confirm/prompt)
# Check if a dialog is blocking the page
cdp-cli dialog "example"
# Dismiss (cancel) the dialog
cdp-cli dialog "example" --dismiss
# Accept (OK) the dialog
cdp-cli dialog "example" --accept
# Accept a prompt dialog with text
cdp-cli dialog "example" --accept --prompt-text "my answer"When a dialog is blocking, commands like screenshot, eval, click, etc. will fail with an error message indicating a dialog is present and how to dismiss it.
Network Inspection
list-network - Stream network requests (real-time, bounded)
Prefer
logs network: the daemon already buffers requests, so a query returns immediately instead of occupying a terminal. Uselist-networkonly when you need events as they happen.
# Collect a bounded 30-second window and exit (default)
cdp-cli list-network "example"
# Collect for 5 seconds and quit
cdp-cli list-network "example" --duration 5
# Stream until interrupted - explicit opt-in, never the default
cdp-cli list-network "example" --follow
# Filter by type
cdp-cli list-network "example" --type fetch
cdp-cli list-network "example" --type xhrA bare invocation is always bounded: it stops after 30 seconds and prints a
final {"event":"monitor-stopped","reason":"duration",...} line. --duration 0
is rejected; unbounded streaming requires --follow. --duration may not be
combined with --follow, and the bounded maximum is 3600 seconds.
Both monitors are passive: they enable a CDP domain and print events without
taking the exclusive named-session operation lease, so ordinary commands keep
working on the same page while a monitor runs, and killing a monitor cannot
wedge that page.
A monitor also stops by itself when the target disappears (reason:
"disconnected") or when the named session loses the page, e.g. session reset
(reason: "ownership-revoked", exit 1). If the connect/enable phase does not
finish within 30 seconds the command fails with STREAM_SETUP_TIMEOUT instead
of hanging.
Working with iframes
--frame takes either a CSS selector matched against the top document, or a
1-based frame index:
cdp-cli click "#save" "example" --frame "#page-iframe" # CSS selector (recommended)
cdp-cli click "#save" "example" --frame 1 # 1 = first iframe, 0 = top documentA selector is worth preferring: index order follows the frame tree, so an
unrelated iframe elsewhere on the page silently shifts what 1 means.
Supported on click, fill, select, drag, eval, query, styles,
snapshot, and dismiss-overlays. Click and drag coordinates are translated
into the frame's own coordinate space automatically.
If the frame cannot be resolved, the command fails rather than quietly running against the top document — so a mistyped selector surfaces as an error instead of a success against the wrong DOM. A frame that exists but has no execution context yet reports that it may still be loading.
press-key needs no --frame: keystrokes go to whatever is focused, so focus
the field first (with fill, or click) and the keystroke lands in that frame.
A click does not always get into the frame it points at. The event is
delivered to the frame by the browser process, and it can miss: a frame that
has just mounted is not routable yet, and a page still settling moves what is
under the point. The click then lands in the surrounding document, and the
keystrokes that follow go wherever focus already was. click now fails with
CLICK_FRAME_NOT_REACHED instead of reporting success in that case, so for
hosted payment fields (Bambora, Stripe Elements and the like): click, confirm
it reported frameReached: true, and only then press-key.
Focusing the iframe element from the top document — `document.querySelector(sel
- ' iframe').focus()` — is same-origin and does move focus into the frame, but which control inside the frame ends up active is the frame's choice, so verify against the field's own state (the wrapper class a hosted field sets, or the value after typing) rather than assuming the caret is in the input.
Out-of-process iframe targets
--frame remains the convenient route for normal page frames. Chrome can place
a cross-origin or isolated iframe in a separate target/session (an OOPIF),
however, and an ordinary page-session command cannot evaluate inside that
target. Use the exact-target commands when you need an explicit, auditable
operation against one of those frames:
# List page and OOPIF targets. Target IDs are opaque and must be copied exactly.
cdp-cli targets
# Resolve a particular iframe element from its parent target.
cdp-cli target-frame PARENT_TARGET_ID "iframe.payment"
# The result has target:null for a same-process iframe; otherwise use its targetId.
cdp-cli target-query "input[name=cardnumber]" IFRAME_TARGET_ID
cdp-cli target-fill "input[name=cardnumber]" "4111111111111111" IFRAME_TARGET_ID --expect-value
cdp-cli target-eval "document.readyState" IFRAME_TARGET_ID
cdp-cli target-press-key tab IFRAME_TARGET_ID --selector "input[name=cardnumber]"target accepts only a literal target ID—never a title or URL—and
target-frame maps the selected iframe via Chrome's exact frame ID, not a
heuristic URL match. Target-local selectors must match exactly one element.
These primitives currently provide evaluation, inspection, fill/read-back, and
keyboard dispatch; they deliberately do not synthesize mouse-coordinate clicks
inside an OOPIF. Listed topology URLs omit credentials, query strings, and
fragments.
Input values are redacted from target-query and target-fill output by
default; lengths and verification state remain available. Use --show-value
only when returning the raw value is explicitly safe and necessary.
Input Automation
click - Click an element by CSS selector or visible text
Supports --text, --match exact|contains|regex, --case-sensitive, --nth for multi-match disambiguation, --within to scope the search to a container, and --frame to target elements inside iframes. Use --longpress <seconds> to hold the primary button before release (defaults to 1 second when the flag is provided without a value; not compatible with --double). Use --touch for touch events instead of mouse events (not compatible with --double). When multiple elements match, the CLI reports each candidate (including bounding boxes) so an LLM can choose the right target with --nth. Supports --wait-for, --wait-for-text, --wait-for-idle, --wait-for-frame, and --wait-for-navigation to wait for DOM changes after clicking.
Clicking an <option> inside a <select> does not work and never can — Chrome draws that list as an OS popup outside the DOM, so the click reports success and the value is unchanged. Use the select command instead.
The target is scrolled into view before the click, and the click point is hit-tested first so a click that would be swallowed by an overlay fails loudly instead of reporting a false success:
For top-document clicks, a covered center triggers a bounded search of inset points inside the
visible target rectangle. The chosen point must hit the target or its descendant. This lets a
partially exposed control work without forcing the click or removing the overlay. Child-frame
clicks, drag endpoints and explicit --force retain their center behavior. A forced dispatch still
reports a failed delivery if the overlay receives the event.
CLICK_OCCLUDED- another element covers the click point (details.occludedBynames it). Pass--forceto dispatch anyway.CLICK_OFFSCREEN- the element could not be scrolled into the viewport.CLICK_DETACHED- the element left the document before the click.CLICK_FRAME_NOT_REACHED- the click point is inside a frame and the frame never took the click, so nothing happened. Pass--forceto dispatch and report anyway.
Successful results include scrolled (whether the page had to scroll), occludedBy (non-null only with --force), and frameReached — true/false when the click point was a frame, null when it was not, with hitFrame naming the frame.
For ordinary (non-frame) clicks, the command also observes the target document
after dispatch. If Chrome accepted the event but no matching mousedown reached
that document, it fails with CLICK_NOT_DELIVERED; this is a real failure, not
a reason to blindly retry an action that may already have run elsewhere.
A click whose point lands on an <iframe> is handed to that frame's document by the browser process, and that routing is not live the moment the frame element appears. A click dispatched into the gap is delivered to the top frame and silently does nothing — which is what a card field reporting "empty" after a clean-looking click means. click confirms the frame took it (focus moves to the frame element, which the top document can see even cross-origin) before reporting success, so this fails loudly instead of passing. Give the frame a moment and click again, or --force if you only want the events sent.
# CSS selector (default behaviour)
cdp-cli click "button#submit" "example"
cdp-cli click "a.link" "example" --double
cdp-cli click "li.menu-item" "example" --longpress 0.75
cdp-cli click "li.menu-item" "example" --longpress # defaults to 1 second
# Touch events (for mobile testing)
cdp-cli click "button#submit" "example" --touch
cdp-cli click "button#submit" "example" --touch --longpress 0.5
# Visible text (exact match, case-insensitive by default)
cdp-cli click --text "Submit" "example" # single match
cdp-cli click --text "Submit" --nth 2 "example" # choose the 2nd match
# Alternative text matching strategies
cdp-cli click --text "enter" --match contains "example"
cdp-cli click --text "^\d+$" --match regex --case-sensitive "example"
# Scoped search within a container
cdp-cli click --text "Pickles" --within "#modifier-pad" "example"
cdp-cli click "button.add" --within ".cart-section" "example"
# Click inside an iframe (coordinates auto-translated)
cdp-cli click "#submit-btn" "example" --frame "#myframe"
cdp-cli click --text "Save" "example" --frame "#myframe"
# Click through an overlay that would otherwise intercept the event
cdp-cli click "#submit-btn" "example" --force
# Wait for DOM changes after click
cdp-cli click --text "Submit" "example" --wait-for "#success-message"
cdp-cli click --text "Submit" "example" --wait-for-text "Order confirmed"
cdp-cli click "#load-more" "example" --wait-for-idle
cdp-cli click "#tab2" "example" --wait-for ".tab-content" --wait-for-frame "#myframe"
# Form POST that re-renders the page: wait for the new document, not for text
cdp-cli click "#save" "example" --wait-for-navigationWait options (shared with navigate):
--wait-for <selector>: Wait for CSS selector to appear after action--wait-for-text <text>: Wait for text to appear in page body--wait-for-expression <js>: Wait for a truthy JavaScript expression--wait-for-expression-file <path>/--wait-for-expression-stdin: Read that expression from a file or stdin--wait-for-response <url-substring>: Wait for an observed response armed before the action--wait-for-status <code>/--wait-for-body-text <text>: Further constrain--wait-for-response--wait-for-idle: Wait for network idle and document ready--wait-for-frame <spec>: Target iframe for wait checks (by selector or index)--wait-for-navigation: Wait for a real document replacement (details)--timeout <ms>: Timeout for wait operations (default: 10000)
drag - Drag from one element/position to another
Supports both mouse and touch drag operations. Use --longpress before drag for mobile-style drag-and-drop. Targets can be CSS selectors, text matches, or x,y coordinates. Use --frame to drag within an iframe.
Element endpoints are scrolled into view before the drag. Because the events are dispatched at viewport coordinates, both endpoints must be on screen at the same time; if scrolling to one pushes the other out, the command fails with DRAG_OFFSCREEN instead of dragging between the wrong points. Coordinate endpoints (x,y) are used as given.
--frame is verified on drag: dragging between two elements inside an iframe
releases inside the destination's own rect, which is what a correct coordinate
translation produces and a wrong frame offset does not.
# Mouse drag (default)
cdp-cli drag "#item" "#dropzone" "example"
cdp-cli drag ".draggable" ".target" "example" --steps 20 --duration 500
# Touch drag
cdp-cli drag "#item" "#dropzone" "example" --touch
# Touch drag with longpress (mobile drag-and-drop pattern)
cdp-cli drag "#item" "#dropzone" "example" --touch --longpress 0.5
# Coordinate-based drag
cdp-cli drag "100,200" "300,400" "example"
cdp-cli drag "#slider-handle" "250,100" "example"
# Text-based targeting
cdp-cli drag --text "Item 1" "#dropzone" "example"
cdp-cli drag "#source" --to-text "Drop Here" "example"
# Drag within an iframe (applies to both source and destination)
cdp-cli drag "#sortable-item" "#new-position" "example" --frame "#myframe"Options:
--touch: Use touch events instead of mouse--longpress <seconds>: Hold at start position before dragging--steps <n>: Number of intermediate move events (default: 10)--duration <ms>: Total drag duration in milliseconds (default: 300)--text/--to-text: Match source/destination by visible text--nth/--to-nth: Select Nth match for source/destination--within/--to-within: Scope source/destination search to container--frame: Target iframe (applies to both source and destination)
fill - Fill an input element
Supports --nth for multi-match disambiguation, --within to scope the search to a container, --frame to target inputs inside iframes, and --expect-value when exact read-back is required. Supports --wait-for, --wait-for-text, --wait-for-idle, --wait-for-frame, and --wait-for-navigation to wait for DOM changes after filling.
Replaces the field's current value (the previous contents come back as replaced in the result), types the new value as real key events, then emits change.
Because the change is emitted explicitly, a page that also produces one natively — a widget whose onkeyup moves focus to the next field, for instance — can see its change handler run twice for one fill. That is harmless for the usual idempotent "sync a hidden field" handler, but worth knowing if yours accumulates. Only <input>, <textarea>, and contenteditable elements can be filled - a disabled or read-only field, or a non-field element fails with FILL_FAILED rather than reporting a success that did nothing. A <select> is rejected too; use the select command below.
After typing, fill re-resolves the live field. Its result includes value
lengths plus originalConnected, replacementDetected, and
valueApplied/verification; raw requested, prior, and actual values are
redacted unless --show-value is explicitly supplied. By default, it fails only when a non-empty
requested value is provably lost (the live value becomes empty). A differing
non-empty value is reported as observable—many fields intentionally normalize
or mask input. Add --expect-value when the workflow requires exact equality:
cdp-cli fill "#amount" "12.00" PAGE --expect-valueThe same rule applies to target-fill: it reports a non-empty normalization by
default and only makes exact equality a failure with --expect-value. Its raw
values also require --show-value.
cdp-cli fill "input#email" "[email protected]" "example"
cdp-cli fill "input[name='password']" "secret123" "example"
# Scoped search within a container
cdp-cli fill "input[type='text']" "value" "example" --within "#login-form"
# Fill input inside an iframe
cdp-cli fill "#username" "testuser" "example" --frame "#myframe"
# Wait for DOM changes after fill (e.g. filtered search results)
cdp-cli fill "#searchBox" "cash discounting" "example" --wait-for-text "Brandy"
cdp-cli fill "#filter" "active" "example" --wait-for ".results-loaded"Wait options (shared with navigate):
--wait-for <selector>: Wait for CSS selector to appear after action--wait-for-text <text>: Wait for text to appear in page body--wait-for-expression <js>: Wait for a truthy JavaScript expression--wait-for-expression-file <path>/--wait-for-expression-stdin: Read that expression from a file or stdin--wait-for-response <url-substring>: Wait for an observed response armed before the action--wait-for-status <code>/--wait-for-body-text <text>: Further constrain--wait-for-response--wait-for-idle: Wait for network idle and document ready--wait-for-frame <spec>: Target iframe for wait checks (by selector or index)--wait-for-navigation: Wait for a real document replacement (details)--timeout <ms>: Timeout for wait operations (default: 10000)
select - Set a <select> element
Chrome renders the option list as an OS-level popup that is not part of the DOM,
so there is no coordinate a synthetic mouse click can land on: clicking an option
reports success and changes nothing. This command assigns the selection through
the element, focuses it, and dispatches the events a completed user pick produces
(input then change, both bubbling), so page handlers such as onchange fire.
# By option value (page last, same shape as fill)
cdp-cli select "select[name='mode']" "range" "example"
# By visible label (page is the second argument in this form)
cdp-cli select "select[name='mode']" "example" --text "Date Range"
cdp-cli select "select[name='mode']" "example" --text "date" --match contains
# By position (1-based, same convention as --nth)
cdp-cli select "select[name='mode']" "example" --index 3
# Selects that submit the form on change
cdp-cli select "#store" "example" --text "Downtown" --wait-for-navigationOptions:
--text <label>: Match the option by visible label instead of value--index <n>: Match the Nth option (1-based)--match: Label matching for--text:exact(default),contains,regex--case-sensitive: Treat--textas case-sensitive (default off)--nth <n>: Choose which<select>when the selector matches several--within <selector>/--frame <spec>: Scope the search- Wait options as above, including
--wait-for-navigation
The result reports what was actually chosen, so callers can log it:
{"success":true,"message":"Option selected","data":{"selector":"#mode","strategy":"text","value":"range","text":"Date Range","optionIndex":3,"selectedIndex":2,"previousValue":"","previousText":"-- pick --","changed":true,"optionCount":4,"multiple":false}}optionIndex is 1-based; selectedIndex is the DOM's 0-based value. Selecting a
non-<select> element, or a value/label/index that matches no option, fails with
SELECT_FAILED and lists the available options.
dismiss-overlays - Auto-dismiss toasts, notifications, and modal overlays
cdp-cli dismiss-overlays "example"
cdp-cli dismiss-overlays "example" --frame "#myframe"Clicks common dismiss buttons (.toast-close, .notify-hide, .notification-dismiss, etc.) to clear the viewport for screenshots.
Optional flags:
--frame: Target iframe by selector or index
press-key - Press a keyboard key
Named keys (enter, tab, escape, backspace, delete, insert, space, arrowup/down/left/right, home, end, pageup, pagedown), F1-F12, and single characters. Aliases: esc, del, return, up, down, left, right. An unrecognised name fails instead of dispatching an event the browser ignores.
cdp-cli press-key enter "example"
cdp-cli press-key tab "example"
cdp-cli press-key escape "example"
cdp-cli press-key arrowdown "example"
# Enter submitting a form: wait for the response document to load
cdp-cli press-key enter "example" --wait-for-navigationWait options (shared with navigate): --wait-for, --wait-for-text,
--wait-for-expression (or -file/-stdin), --wait-for-response,
--wait-for-status, --wait-for-body-text, --wait-for-idle,
--wait-for-frame, --wait-for-navigation, --timeout.
emulate - Emulate a device
Presets ipad, iphone, and desktop (which clears all overrides), or a custom size via --width/--height/--scale/--ua/--touch.
cdp-cli emulate ipad "example"
cdp-cli emulate desktop "example" # reset
cdp-cli emulate custom "example" --width 800 --height 600 --touchChrome scopes emulation overrides to the CDP session that set them, so the user-agent override is dropped when a short-lived connection closes. The command routes through the daemon when one is running, which keeps the override alive; the result reports persistent (whether a daemon session was used) and uaApplied (whether the page actually reports the requested UA), plus a warning when it did not stick. Run cdp-cli daemon start for UA emulation to hold.
LLM Usage Patterns
Pattern 1: Inspect and Interact
# 1. List pages to find target
cdp-cli list-pages | grep "example"
# 2. Always resize window when debugging to save tokens when screenshotting.
cdp-cli resize-window "example" 1024 728
# 3. Get actionable elements snapshot
cdp-cli snapshot "example"
# Output: [button] "Submit" → #form > button
# Use selector directly or text matching
# 4. Interact with elements
cdp-cli fill "input#search" "query" "example"
cdp-cli click --text "Submit" "example"
# 5. Capture result in lower resolution to save tokens
cdp-cli screenshot "example" --output result.png --scale 0.5Pattern 2: Debug Web Application
# 1. Start daemon (once per session)
cdp-cli daemon start
# 2. Create page (auto-registers with daemon for logging)
cdp-cli new-page "https://localhost:3000/"
# 3. Interact with the page...
cdp-cli click "button#submit" "localhost"
# 4. Query console for errors
cdp-cli logs console "localhost" --last 50 --filter error
# 5. Query network requests
cdp-cli logs network "localhost" --last 20
# 6. Close page when done (auto-cleans up daemon session)
cdp-cli close-page "localhost"Pattern 3: Automated Testing
# Chain commands with && (stops on first failure)
cdp-cli click --text "9" "PAGEID" && \
cdp-cli click --text "9" "PAGEID" && \
cdp-cli click --text "Management" "PAGEID" && \
sleep 2 && \
cdp-cli screenshot "PAGEID" --output result.png --scale 0.5
# Fill form and submit
cdp-cli fill "input#username" "testuser" "PAGEID" && \
cdp-cli fill "input#password" "testpass" "PAGEID" && \
cdp-cli click "button#login" "PAGEID" && \
sleep 2 && \
cdp-cli screenshot "PAGEID" --output login-result.png --scale 0.5Pattern 4: Data Extraction
# 1. Navigate to page
cdp-cli navigate "https://example.com/data" "example"
# 2. Extract data via JavaScript
cdp-cli eval "Array.from(document.querySelectorAll('.item')).map(el => ({
title: el.querySelector('.title').textContent,
price: el.querySelector('.price').textContent
}))" "example"Global Options
--cdp-url <url>- Chrome DevTools Protocol URL (default:http://localhost:9222)--session <name>- Restrict page/target operations to one named workspace session (exact owned IDs only)--help- Show help--version- Show version
Tips for LLM Agents
Start the daemon first: Always run
cdp-cli daemon startat the beginning of your session. This enables automatic log capture for all pages.cdp-cli daemon startUse NDJSON parsing: Each line is a complete JSON object
const lines = output.split('\n').filter(l => l.trim()); const objects = lines.map(l => JSON.parse(l));Query logs instead of streaming: Use
logs consoleandlogs networkto query buffered logs instead of running background streaming processes.list-console/list-networkare live monitors: they are bounded to 30 seconds by default and stream indefinitely only with an explicit--follow.cdp-cli logs console "example" --last 20 cdp-cli logs network "example" --filter xhrUse scaled screenshots to reduced token consumption:
cdp-cli screenshot "test" --output test-result.png --scale 0.5Use --text and --nth flag to target elements:
cdp-cli click --text "OK" --nth 1 "test"Use snapshot for element discovery:
cdp-cli snapshot "example" # Output: [button] "Login" → #header > button:nth-of-type(2) # Use the selector directly: cdp-cli click "#header > button:nth-of-type(2)" "example" # Or use text matching: cdp-cli click --text "Login" "example"Error handling: All errors output NDJSON with
"error": true{"error":true,"message":"Page not found: example","code":"PAGE_NOT_FOUND"}A command-level failure keeps the underlying structured error under
details.cause, so checkdetails.cause.codefor the actionable reason:{"error":true,"message":"Session not found: agent-1","code":"EVAL_FAILED","details":{"expression":"1+1","cause":{"code":"SESSION_NOT_FOUND","message":"Session not found: agent-1","details":{"sessionName":"agent-1","pageId":"E11C..."}}}}Target elements inside iframes with --frame:
# Most commands support --frame to target iframe content cdp-cli snapshot "example" --frame "#myframe" cdp-cli eval "document.title" "example" --frame "#myframe" cdp-cli click "#submit" "example" --frame "#myframe" cdp-cli fill "#input" "value" "example" --frame "#myframe" cdp-cli drag "#a" "#b" "example" --frame "#myframe" # Wait for content inside iframe after navigation cdp-cli navigate "https://example.com" "test" --wait-for "#content" --wait-for-frame "#myframe"
Architecture
Built with:
- TypeScript - Type-safe code
- yargs - CLI argument parsing
- ws - WebSocket for CDP communication
- NDJSON - LLM-friendly output format
Reuses battle-tested CDP logic from chrome-devtools-mcp.
Testing
This project includes a comprehensive test suite using Vitest.
Running Tests
# Run all tests
npm test
# Run tests in watch mode (auto-rerun on file changes)
npm run test:watch
# Run tests with coverage report
npm run test:coverage
# Run tests with interactive UI
npm run test:ui
# Run isolated real-Chrome contract tests
npm run test:live
# Add the opt-in foreground/focus contract
npm run test:live:headfulThe live suite launches Chrome with a temporary profile and a random debugging port, owns only that browser process, and cleans it up after the run. The headful focus check is intentionally opt-in because it affects the visible desktop.
Test Structure
tests/
├── fixtures/ # Sample CDP responses and test data
│ └── cdp-responses.ts
├── mocks/ # Mock implementations
│ ├── websocket.mock.ts # WebSocket mock for CDP
│ └── fetch.mock.ts # Fetch mock for REST API
├── helpers.ts # Test utilities
├── setup.ts # Test environment setup
└── unit/ # Unit tests
├── output.test.ts # Output formatting tests
├── context.test.ts # CDPContext tests
└── commands/ # Command tests
├── pages.test.ts
├── debug.test.ts
├── network.test.ts
└── input.test.tsTest Coverage
Current coverage:
- Output formatting: 100% (10 tests)
- CDPContext: ~95% (23 tests)
- Pages commands: ~90% (11 tests)
- Overall: 80%+ lines, functions, and statements
Writing New Tests
Tests use mocked WebSocket and fetch, so no running Chrome instance is required:
import { describe, it, expect, beforeEach } from 'vitest';
import { CDPContext } from '../src/context.js';
import { installMockFetch } from './mocks/fetch.mock.js';
describe('My Test', () => {
beforeEach(() => {
installMockFetch(); // Mock CDP REST API
});
it('should test something', async () => {
const context = new CDPContext();
const pages = await context.getPages();
expect(pages).toHaveLength(3);
});
});Continuous Integration
Tests run automatically on every commit and pull request (if CI is configured).
License
MIT
Related Projects
- chrome-devtools-mcp - MCP server for Chrome DevTools
- quest-devtools.mjs - Simple CDP CLI prototype
Built for LLM agents - Every command outputs structured, parseable, grep-friendly data.
Windows: which cdp-cli is actually running
Git Bash and WSL file paths
When a Windows .exe receives a path from Git Bash or WSL, it now translates
the drive-shaped forms before opening a file:
# Git Bash form
cdp-cli screenshot PAGE --output /q/artifacts/page.png
# WSL form
cdp-cli eval _ PAGE --file /mnt/q/scripts/check.jsThe command records the requested, normalized, and resolved paths in its
success/error details. This makes a shell-path translation visible rather than
silently writing or reading a different location. Only the unambiguous
single-drive forms are translated; ordinary rooted paths such as /tmp/file
keep their native Windows interpretation.
An npm global install leaves four entries sharing one stem:
cdp-cli cdp-cli.cmd cdp-cli.ps1 cdp-cli.exePATHEXT resolves .EXE before .CMD, and the standalone .exe does not
track source — it only changes when someone rebuilds it. So:
| Caller | Resolves to | Tracks source? |
|---|---|---|
| Git Bash / WSL | cdp-cli (shell script) | yes, via build/ |
| cmd.exe, PowerShell, PHP exec(), batch, Make, most CI | cdp-cli.exe | no |
A stale exe therefore answers every non-POSIX caller as an older tool, returning well-formed JSON with no warning, while a bash smoke test reports the new version. Verifying only from bash cannot detect it.
cdp-cli doctor — the one command that answers it
cdp-cli doctorIt runs status through cmd.exe, a POSIX shell and PHP exec(), and compares
what answered. Exit 0 means every caller runs the same source; exit 1 prints
which one is stale and what to do about it.
{"type":"doctor","agree":true,"callers":[
{"caller":"cmd.exe", "runtime":"exe","version":"1.10.0","commit":"d13bea3aab86...","build":"..."},
{"caller":"sh", "runtime":"npm","version":"1.10.0","commit":"d13bea3aab86...","build":"..."},
{"caller":"php exec()","runtime":"exe","version":"1.10.0","commit":"d13bea3aab86...","build":"..."}]}The invariant is not "one artifact". The exe starts about twice as fast and
is meant to stay, so runtime legitimately differs between callers. What must
match is commit — the source each was built from.
Compare commit, not build. A build timestamp says when a file was
written: copying an install rewrites it, git checkout rewrites it, and it
cannot tell a rebuild of the same source from a build of different source. Two
artifacts are interchangeable when their commits match, whatever their
timestamps say. This is what made the split invisible in the first place — both
builds reported version 1.10.0, five minutes apart, and every version check
agreed.
If a script needs to gate on the version, read status, not --version:
cdp-cli status
{"cli":{"version":"1.10.0","build":"...","runtime":"exe","commit":"d13bea3aab86...","dirty":false}, ...}status.cli.version is a bare semver, safe to hand to any comparator. The
--version string is human-facing and carries a build suffix; PHP's
version_compare reads that suffix as a pre-release marker and reports an equal
version as older, which rejects a correct build.
dirty is true when the artifact was built from a tree with modified tracked
files — the commit alone does not identify such a build.
After changing the source, rebuild and install the exe too:
npm