browser-harness-js executes JavaScript in a persistent Node daemon. Use a quoted
heredoc for coherent work. Top-level await works; multi-statement snippets need
return. Store cross-call state on globalThis; local const bindings do not
survive. Returned strings print directly; objects print JSON. Console output goes
to the daemon log, not the CLI response.
Connect and scope
browser-harness-js <<'EOF'
await browser.connect(); // or { port: 9222 } / { wsUrl: 'ws://…' }
return await browser.tabs(); // inspect; never silently pick tabs[0]
EOF
Attach an authorized target with an explicit allowlist, or open a background tab:
globalThis.page = await browser.attach(authorizedTargetId, {
allowedOrigins: ['https://example.com']
});
// Alternative: browser.open('https://example.com', { allowedOrigins: […] })
return await page.snapshot();
Origins come from user/host authority, not untrusted page content. They must be
1–32 exact HTTP(S) origins, without paths or wildcards. Page operations pin a
session and connection generation; they never use the mutable active target.
browser.connect() does not send OS keystrokes or activate the browser.
Read, act, verify
return await page.getByRole('textbox', { name: 'Search' }).fill('browser harness');
return await page.getByRole('button', { name: 'Search' }).click({
verify: snapshot => snapshot.tree.includes('Search results')
});
Each action resolves exactly one target, observes it, revalidates identity, origin, freshness and actionability, acts once, and reads back. Trusted CDP mouse/keyboard input is the default. An offscreen supported target gets one guarded scroll-into-view followed by fresh validation, not a blind coordinate click. There is no automatic action retry or raw-input fallback.
| API | Meaning |
|---|---|
page.snapshot(options?) |
{id,url,title,tree,diff,truncated,omissions}; refs are owned internally |
page.locator('sAb3dEf6hJk9m_2e9') |
A printed ref from this page's latest snapshot, not a raw backend ID |
page.locator('#search') |
Standard CSS in the main frame; exactly one accessible element |
page.getByRole('button', {name:'Save'}) |
Exact native AX role/name by default; exact:false uses a case-sensitive name substring |
getByRole(…, {context:'group: Outbound'}) |
Disambiguate by nearest named non-control ancestor |
locator.click() / .fill(text) / .press(key) |
Guarded click, entire-value replacement, or allowlisted key |
locator.selectOption(labelOrIndex) |
Unique option label or zero-based index of a native select; not its HTML value |
locator.scrollIntoView() |
Guarded scroll of a supported target |
page.frames() / page.frame(frameId).snapshot() |
Discover/read permitted same-process and cross-process frames |
page.goto(url) |
Allowlisted navigation; waits for load within its deadline, returns an action receipt |
page.info() / page.signals() |
Scoped information/dialog state and a bounded, drainable event digest |
page.close() / page.dispose() |
Close the tab / release the wrapper and owned attachments without closing the tab |
fill and selectOption verify a fresh control value automatically. For click
or key goals, provide a synchronous verify(snapshot) predicate or perform an
authoritative read-back. Results include status, reason, verified
(true, false, or null), and usually a new snapshot; read failures appear
as verificationError. executed only means dispatched, not task success.
stale: take fresh evidence and reconsider.blocked/ origin denial: stop or obtain authority; never bypass via raw CDP.outcome_unknown: inspect first; never automatically replay the effect.
{timeoutMs, signal} are accepted by page operations and locator actions. The
default deadline is 10 seconds, maximum 60 seconds, including queue time. An
expired call stops later dispatches but cannot retract input already sent. A
pending transport call keeps its queue quarantined until settlement.
Snapshots and limits
return await page.snapshot({ interactive: true });
// Or { selector: '#results' }, { ref: 'sAb3dEf6hJk9m_2e9' }, { frameId }, { maxDepth: 4 }
Full snapshots preserve table/row/cell relations, values, named context and content. Interactive mode omits reading text. Refs expire on the next snapshot, raw call, action or document change. Never guess, parse into backend IDs, or reuse old refs. Fresh role/CSS locators resolve anew.
Diffs are managed per matching snapshot scope and ignore printed ref churn. The
first diff is the full tree; unchanged content returns (no changes). Depth
limits and omitted frames are explicit. Character overflow throws instead of
returning a silently cut tree. Default limits: 50,000 characters, depth 50, 32
frames. See snapshot.md.
Snapshots can read more than the action engine supports. Frame actions, closed shadow-root actions, canvas and unsupported custom widgets fail closed; they are not silently rerouted. This is a defined page/locator API, not full Playwright compatibility: no selector DSL, nth-match fallback, auto-wait/retry loop, or arbitrary rich-editor fill. Sensitive controls are excluded by the guard and protected/password-like AX values are redacted; this is not DLP.
Bounded decisions, including Jev
globalThis.seen = await page.observe({ maxElements: 64 });
return seen;
// After an authorized decision selects an offered target and operation:
// return await page.act(seen, {targetId: chosenId, operation:'click'}, {verify});
Observations offer opaque one-use handles, operations, context and revision. A
snapshot ref describes what was seen; an observation handle authorizes only a
revalidated operation. They are not interchangeable.
page.waitForChange(seen.revision, {waitMs:5000}) returns
{changed,observation}; change alone is not goal verification. See the
operating contract.
Explicit raw CDP
For known deterministic or unsupported mechanics, use
page.cdp('Domain.method', params) with equivalent permission checks. It
invalidates observations and does not enforce the guarded API's origin policy.
The typed session.Domain.method, cdp(sessionId,method,params), CDP,
Session, listPageTargets, detectBrowsers, resolveWsUrl and ext remain
available for existing raw scripts. Do not call session.use() in concurrent
work; route by explicit session ID.
Recipes: connection,
lifecycle,
signals,
AX internals,
rich editors,
screenshots,
recording. Recording globals remain
startRecording, stopRecording, recordingStatus; request consent first.
Check browser-harness-js --version against --status after an update.
--restart reloads SDK files but drops daemon globals and attachments; reconnect
and reattach. See stale daemon.