Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 61 additions & 9 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,8 @@ jobs:
timeout-minutes: 10
strategy:
matrix:
# 20 is the supported floor; 22 exercises live streaming and CDP
# attach mode (global WebSocket client).
node-version: [20, 22]
# 20 is the supported floor; 22/24 support the global WebSocket client.
node-version: [20, 22, 24]
steps:
- name: Check out repository
# v4.2.2
Expand All @@ -30,11 +29,64 @@ jobs:
uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b
with:
node-version: ${{ matrix.node-version }}
- name: Run tests
run: npm test
- name: Validate shell syntax
run: bash -n scripts/*.sh
- name: Validate source, manifest, shell scripts, and tests
run: npm run validate
- name: Run ShellCheck
run: shellcheck scripts/*.sh
- name: Validate plugin manifest
run: node scripts/check-manifest.mjs

browser:
runs-on: ${{ matrix.os }}
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
node-version: [22, 24]
steps:
- name: Check out repository
# v4.2.2
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
persist-credentials: false
- name: Set up Node.js for agent-browser installation
# agent-browser 0.33.2 requires Node 24 to install its native CLI.
# v4.0.3
uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b
with:
node-version: 24
- name: Install pinned agent-browser
run: |
npm install --global --prefix "$RUNNER_TEMP/agent-browser" agent-browser@0.33.2
echo "$RUNNER_TEMP/agent-browser/bin" >> "$GITHUB_PATH"
- name: Install Chromium and platform prerequisites
shell: bash
run: |
if [[ "$RUNNER_OS" == "Linux" ]]; then
agent-browser install --with-deps
else
agent-browser install
fi
- name: Export installed Chromium executable
shell: bash
run: |
python3 - <<'PY' >> "$GITHUB_ENV"
import os
from pathlib import Path

cache = Path.home() / ".agent-browser" / "browsers"
binaries = [p for p in cache.rglob("*")
if p.name in ("chrome", "Google Chrome for Testing")
and p.is_file() and os.access(p, os.X_OK)]
if len(binaries) != 1:
raise SystemExit(f"Expected one installed Chromium executable, found {binaries}")
print(f"HERDR_BROWSER_CHROMIUM={binaries[0]}")
PY
- name: Set up Node.js under test
# v4.0.3
uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b
with:
node-version: ${{ matrix.node-version }}
- name: Require real-browser integration coverage
run: npm run test:integration
- name: Require saved QA browser coverage
run: npm run test:qa
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

## Unreleased

- Add an on-demand page text view: press `s` for the accessibility-tree
snapshot (interactive elements carry `@eN` refs), `n` for the full recent
request table, and `d` for a ref-normalized diff against the last snapshot.
Works on both backends (agent-browser `snapshot`/`network requests`, CDP
`Accessibility`/`Network`); any page-driving action dismisses it.
- Select attached or locally launched browser tabs explicitly from a title/URL
picker. Clear stale frames and pending input when the selected tab closes;
require another selection before forwarding input.
Expand Down
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ guides to this plugin and its three siblings (Guard, Swarm, Conductor).
guesses; keyboard input, history, reload, and wheel scrolling are supported.
- **Adaptive rendering** — Kitty graphics when available, ANSI symbols through
chafa otherwise, and a text-only last resort.
- **Read the page as text** — press `s` for the accessibility-tree snapshot
(interactive elements get `@eN` refs), `n` for the full recent request table,
and `d` to diff the page against your last snapshot; the screenshot returns
on any page-driving action.
- **Built-in recording** — capture the workspace session as WebM.
- **Localhost integration** — Cmd/Ctrl+click a local development URL in Herdr to
open it in the workspace browser pane.
Expand Down Expand Up @@ -160,6 +164,9 @@ Use these controls to drive the shared session directly:
| `j` / `k` | Scroll down / up |
| Space | Scroll down |
| Mouse wheel | Scroll the page |
| `s` | Toggle the accessibility-tree snapshot (interactive elements carry `@eN` refs) |
| `n` | Toggle the recent request table |
| `d` | Diff the current page against the last snapshot (press `s` first) |
| `Esc` | Cancel the active prompt |
| `q` | Close the viewer pane |

Expand Down Expand Up @@ -499,6 +506,7 @@ npm run doctor
npm run build
npm run validate
npm run test:integration
npm run test:qa
herdr plugin link .
```

Expand All @@ -514,7 +522,15 @@ browser coverage; optional browser tests skip when their prerequisites are
unavailable. `npm run test:integration` requires both an installed Chrome or
Chromium and agent-browser with its engine installed, on Node 22+. It fails
instead of silently skipping either real-browser path. Tests use a local HTTP
fixture and isolated browser sessions.
fixture and isolated browser sessions. `npm run test:qa` requires agent-browser
with its engine installed and runs saved desktop/mobile scenarios, including
assertion failures and browser error evidence. CI installs pinned agent-browser
0.33.2 using Node 24, then tests the plugin on Node 22/24 on Linux and macOS.

Before release, require local validation, both strict browser commands, and the
hosted CI matrix to pass for the reviewed commit. A passing suite with browser
coverage skipped is insufficient. Manually verify Kitty graphics and symbol
rendering in Herdr on macOS and Linux.

For backend choices and the remaining readiness work, see the
[readiness assessment](docs/readiness.md).
Expand Down
66 changes: 66 additions & 0 deletions bin/cdp.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
// creates or closes targets and exposes no listening port of its own.
import http from "node:http";
import net from "node:net";
import { formatAxNodes, formatRequests } from "./page-text.mjs";

// Attach mode needs a WebSocket client; Node grew a global one in 22.
// On Node 20 the pane keeps working in agent-browser mode — callers gate on
Expand Down Expand Up @@ -262,6 +263,10 @@ export function makeCdpBrowser(endpointInput, opts = {}) {
let attachTimeMs = 0;
let connectedOnce = false;
let selection = 0;
let accessibilitySessionId = null;
let networkSessionId = null;
const networkRows = [];
const networkById = new Map();
const targetError = () => Object.assign(new Error("select a tab with t before sending input"), { code: "TARGET_GONE" });
const requireTarget = (expected = pageSessionId) => {
if (!expected || expected !== pageSessionId || !pinnedTargetId || session?.dead) throw targetError();
Expand All @@ -285,6 +290,10 @@ export function makeCdpBrowser(endpointInput, opts = {}) {
pinnedTargetId = null;
pageSessionId = null;
lastMeta = null;
accessibilitySessionId = null;
networkSessionId = null;
networkRows.length = 0;
networkById.clear();
emit({ type });
};

Expand Down Expand Up @@ -352,7 +361,37 @@ export function makeCdpBrowser(endpointInput, opts = {}) {
}
};

const recordNetworkEvent = (m) => {
if (!networkSessionId || m.sessionId !== pageSessionId || m.sessionId !== networkSessionId) return;
const p = m.params ?? {};
if (m.method === "Network.requestWillBeSent") {
const row = {
method: p.request?.method ?? "",
status: null,
type: p.type ?? "",
url: p.request?.url ?? "",
};
networkRows.push(row);
networkById.set(p.requestId, row);
if (networkRows.length > 200) {
const removed = networkRows.shift();
for (const [requestId, current] of networkById) {
if (current === removed) networkById.delete(requestId);
}
}
return;
}
const row = networkById.get(p.requestId);
if (!row) return;
if (m.method === "Network.responseReceived") row.status = p.response?.status ?? null;
else if (m.method === "Network.loadingFailed") row.status = p.errorText || "failed";
};

const onCdpEvent = (m) => {
if (m.method?.startsWith("Network.")) {
recordNetworkEvent(m);
return;
}
if (m.method === "Page.screencastFrame" && pageSessionId && m.sessionId === pageSessionId) {
lastMeta = m.params.metadata ?? null;
emit({
Expand Down Expand Up @@ -516,6 +555,33 @@ export function makeCdpBrowser(endpointInput, opts = {}) {
try { await pinTarget(next); }
catch (err) { clearTarget("target_gone"); throw err; }
},
// Accessibility and Network stay opt-in: enabling either domain is the
// one honest footprint made only after the user requests its view.
async snapshotText() {
const sid = requireTarget();
try {
if (accessibilitySessionId !== sid) {
await session.send("Accessibility.enable", {}, sid);
requireTarget(sid);
accessibilitySessionId = sid;
}
const { nodes = [] } = await session.send("Accessibility.getFullAXTree", {}, requireTarget(sid));
return formatAxNodes(nodes.map(({ nodeId, role, name, childIds }) => ({ nodeId, role, name, childIds })));
} catch (err) {
if (/wasn't found|not found|not supported|unknown method/i.test(err?.message ?? ""))
throw new Error("browser has no Accessibility domain");
throw err;
}
},
async networkRequests() {
const sid = requireTarget();
if (networkSessionId !== sid) {
await session.send("Network.enable", {}, sid);
requireTarget(sid);
networkSessionId = sid;
}
return formatRequests(networkRows);
},
async open(u) {
await session.send("Page.navigate", { url: u }, requireTarget());
},
Expand Down
121 changes: 121 additions & 0 deletions bin/page-text.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// --- Page snapshots ---------------------------------------------------------

const INTERACTIVE_ROLES = new Set([
"button", "link", "textbox", "checkbox", "combobox", "radio",
"menuitem", "option", "tab", "switch", "slider", "spinbutton", "searchbox",
]);

function axValue(value) {
if (value && typeof value === "object" && "value" in value) return String(value.value ?? "");
return String(value ?? "");
}

/** Format a CDP accessibility tree as indented, referenceable text. */
export function formatAxNodes(nodes) {
const list = Array.isArray(nodes) ? nodes : [];
const byId = new Map(list.map((node) => [node.nodeId, node]));
const children = new Set(list.flatMap((node) => Array.isArray(node.childIds) ? node.childIds : []));
const roots = list.filter((node) => !children.has(node.nodeId));
const lines = [];
const visited = new Set();
let interactive = 0;

const walk = (node, depth) => {
if (!node || visited.has(node.nodeId)) return;
visited.add(node.nodeId);
const role = axValue(node.role) || "unknown";
const name = axValue(node.name);
let line = `${" ".repeat(depth)}${role}`;
if (name) line += ` ${JSON.stringify(name)}`;
if (INTERACTIVE_ROLES.has(role.toLowerCase())) line += ` @e${++interactive}`;
lines.push(line);
for (const childId of Array.isArray(node.childIds) ? node.childIds : []) {
walk(byId.get(childId), depth + 1);
}
};

for (const root of roots) walk(root, 0);
for (const node of list) walk(node, 0);
return { title: `page snapshot (${interactive} interactive)`, lines };
}

/** Parse an agent-browser interactive snapshot without changing its refs. */
export function parseAgentSnapshot(text) {
const input = String(text ?? "").replace(/\r\n?/g, "\n").split("\n");
const headers = [];
while (input.length && /^(?:Page|URL)\s*:/i.test(input[0])) {
headers.push(input.shift().replace(/^(?:Page|URL)\s*:\s*/i, ""));
}
while (input.length && input[0] === "") input.shift(); // blank separator line
while (input.length && input.at(-1) === "") input.pop();
return { title: headers.join(" — ") || "page snapshot", lines: input };
}

// --- Network requests -------------------------------------------------------

function redactUrl(value) {
try {
const url = new URL(String(value));
url.username = "";
url.password = "";
url.search = "";
url.hash = "";
return url.href;
} catch {
return String(value ?? "").replace(/([a-z][a-z\d+.-]*:\/\/)[^/@\s]+@/i, "$1").split(/[?#]/, 1)[0];
}
}

/** Format network request rows as a compact, redacted table. */
export function formatRequests(rows) {
const values = (Array.isArray(rows) ? rows : []).map((row) => [
String(row.method ?? ""),
row.status == null ? "…" : String(row.status),
String(row.type ?? ""),
redactUrl(row.url),
]);
const headings = ["METHOD", "STATUS", "TYPE"];
const widths = headings.map((heading, index) => Math.max(heading.length, ...values.map((row) => row[index].length)));
const lines = [
[headings[0].padEnd(widths[0]), headings[1].padEnd(widths[1]), headings[2].padEnd(widths[2]), "URL"].join(" "),
...values.map((row) => [row[0].padEnd(widths[0]), row[1].padEnd(widths[1]), row[2].padEnd(widths[2]), row[3]].join(" ")),
];
return { title: `requests (${values.length})`, lines };
}

// --- Snapshot differences --------------------------------------------------

function normalizeRefs(line) {
return String(line)
.replace(/@e\d+\b/g, "") // CDP formatAxNodes refs
.replace(/,?\s*ref=e\d+\b/g, "") // agent-browser snapshot refs
.replace(/\[\s*\]/g, "") // empty attribute list left behind
.replace(/[ \t]+$/g, "");
}

/** Diff snapshots while ignoring volatile interactive-element references. */
export function diffSnapshot(prevLines, nextLines) {
const previous = (Array.isArray(prevLines) ? prevLines : []).map(normalizeRefs);
const next = (Array.isArray(nextLines) ? nextLines : []).map(normalizeRefs);
const previousCounts = new Map();
for (const line of previous) previousCounts.set(line, (previousCounts.get(line) || 0) + 1);
const added = [];
for (const line of next) {
const count = previousCounts.get(line) || 0;
if (count) previousCounts.set(line, count - 1);
else added.push(line);
}
const nextCounts = new Map();
for (const line of next) nextCounts.set(line, (nextCounts.get(line) || 0) + 1);
const removed = [];
for (const line of previous) {
const count = nextCounts.get(line) || 0;
if (count) nextCounts.set(line, count - 1);
else removed.push(line);
}
if (!added.length && !removed.length) return { title: "snapshot diff (no change)", lines: [] };
return {
title: `snapshot diff (+${added.length} -${removed.length})`,
lines: [...added.map((line) => `+ ${line}`), ...removed.map((line) => `- ${line}`)],
};
}
Loading
Loading