Skip to content

Commit ed1ac2f

Browse files
committed
feat(browser): add CDP role snapshot fallback
1 parent 0ca9c4d commit ed1ac2f

13 files changed

Lines changed: 1026 additions & 37 deletions

docs/cli/browser.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ Detailed guidance: [Browser troubleshooting](/tools/browser#cdp-startup-failure-
5555
```bash
5656
openclaw browser status
5757
openclaw browser doctor
58+
openclaw browser doctor --deep
5859
openclaw browser start
5960
openclaw browser start --headless
6061
openclaw browser stop
@@ -63,6 +64,8 @@ openclaw browser --browser-profile openclaw reset-profile
6364

6465
Notes:
6566

67+
- `doctor --deep` adds a live snapshot probe. It is useful when basic CDP
68+
readiness is green but you want proof that the current tab can be inspected.
6669
- For `attachOnly` and remote CDP profiles, `openclaw browser stop` closes the
6770
active control session and clears temporary emulation overrides even when
6871
OpenClaw did not launch the browser process itself.

docs/tools/browser-control.md

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@ a clear 501 error.
7575
What still works without Playwright:
7676

7777
- ARIA snapshots
78+
- Role-style accessibility snapshots (`--interactive`, `--compact`,
79+
`--depth`, `--efficient`) when a per-tab CDP WebSocket is available. This is
80+
a fallback for inspection and ref discovery; Playwright remains the primary
81+
action engine.
7882
- Page screenshots for the managed `openclaw` browser when a per-tab CDP
7983
WebSocket is available
8084
- Page screenshots for `existing-session` / Chrome MCP profiles
@@ -84,7 +88,7 @@ What still needs Playwright:
8488

8589
- `navigate`
8690
- `act`
87-
- AI snapshots / role snapshots
91+
- AI snapshots that depend on Playwright's native AI snapshot format
8892
- CSS-selector element screenshots (`--element`)
8993
- full browser PDF export
9094

@@ -256,9 +260,12 @@ OpenClaw supports two “snapshot” styles:
256260
- Output: the accessibility tree as structured nodes.
257261
- Actions: `openclaw browser click ax12` works when the snapshot path can bind
258262
the ref through Playwright and Chrome backend DOM ids.
259-
- If Playwright is unavailable, ARIA snapshots can still be useful for
260-
inspection, but refs may not be actionable. Re-snapshot with `--format ai`
261-
or `--interactive` when you need action refs.
263+
- If Playwright is unavailable, ARIA snapshots can still be useful for
264+
inspection, but refs may not be actionable. Re-snapshot with `--format ai`
265+
or `--interactive` when you need action refs.
266+
- Docker proof for the raw-CDP fallback path: `pnpm test:docker:browser-cdp-snapshot`
267+
starts Chromium with CDP, runs `browser doctor --deep`, and verifies role
268+
snapshots include link URLs, cursor-promoted clickables, and iframe metadata.
262269
263270
Ref behavior:
264271

docs/tools/browser.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ agent automation and verification.
3535

3636
```bash
3737
openclaw browser --browser-profile openclaw doctor
38+
openclaw browser --browser-profile openclaw doctor --deep
3839
openclaw browser --browser-profile openclaw status
3940
openclaw browser --browser-profile openclaw start
4041
openclaw browser --browser-profile openclaw open https://example.com

extensions/browser/src/browser/cdp.internal.test.ts

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
type RawAXNode,
1717
snapshotAria,
1818
snapshotDom,
19+
snapshotRoleViaCdp,
1920
} from "./cdp.js";
2021

2122
/**
@@ -77,6 +78,16 @@ async function startMockWsServer(handle: CdpReplyHandler) {
7778
params?: Record<string, unknown>;
7879
};
7980
handle(msg, socket);
81+
if (
82+
msg.method === "Page.enable" ||
83+
msg.method === "Runtime.enable" ||
84+
msg.method === "Network.enable" ||
85+
msg.method === "DOM.enable" ||
86+
msg.method === "Accessibility.enable" ||
87+
msg.method === "Runtime.runIfWaitingForDebugger"
88+
) {
89+
socket.send(JSON.stringify({ id: msg.id, result: {} }));
90+
}
8091
});
8192
});
8293
return {
@@ -475,6 +486,204 @@ describe("cdp internal", () => {
475486
});
476487
});
477488

489+
describe("snapshotRoleViaCdp", () => {
490+
it("builds role refs, promotes cursor-interactive nodes, and appends link urls", async () => {
491+
const server = await startMockWsServer((msg, socket) => {
492+
if (msg.method === "Accessibility.enable" || msg.method === "Page.enable") {
493+
socket.send(JSON.stringify({ id: msg.id, result: {} }));
494+
return;
495+
}
496+
if (msg.method === "Accessibility.getFullAXTree") {
497+
socket.send(
498+
JSON.stringify({
499+
id: msg.id,
500+
result: {
501+
nodes: [
502+
{
503+
nodeId: "1",
504+
role: { value: "RootWebArea" },
505+
name: { value: "" },
506+
childIds: ["2", "3", "4"],
507+
},
508+
{
509+
nodeId: "2",
510+
role: { value: "button" },
511+
name: { value: "Save" },
512+
backendDOMNodeId: 22,
513+
childIds: [],
514+
},
515+
{
516+
nodeId: "3",
517+
role: { value: "link" },
518+
name: { value: "Docs" },
519+
backendDOMNodeId: 33,
520+
childIds: [],
521+
},
522+
{
523+
nodeId: "4",
524+
role: { value: "generic" },
525+
name: { value: "" },
526+
backendDOMNodeId: 44,
527+
childIds: [],
528+
},
529+
],
530+
},
531+
}),
532+
);
533+
return;
534+
}
535+
if (msg.method === "Runtime.evaluate") {
536+
const expression =
537+
typeof msg.params?.expression === "string" ? msg.params.expression : "";
538+
if (expression.includes('querySelectorAll("*"')) {
539+
socket.send(
540+
JSON.stringify({
541+
id: msg.id,
542+
result: {
543+
result: {
544+
value: [
545+
{
546+
text: "Clickable Card",
547+
tagName: "div",
548+
hasCursorPointer: true,
549+
hasOnClick: true,
550+
},
551+
],
552+
},
553+
},
554+
}),
555+
);
556+
return;
557+
}
558+
socket.send(JSON.stringify({ id: msg.id, result: { result: { value: true } } }));
559+
return;
560+
}
561+
if (msg.method === "DOM.getDocument") {
562+
socket.send(JSON.stringify({ id: msg.id, result: { root: { nodeId: 1 } } }));
563+
return;
564+
}
565+
if (msg.method === "DOM.querySelectorAll") {
566+
socket.send(JSON.stringify({ id: msg.id, result: { nodeIds: [44] } }));
567+
return;
568+
}
569+
if (msg.method === "DOM.describeNode") {
570+
socket.send(
571+
JSON.stringify({
572+
id: msg.id,
573+
result: { node: { backendNodeId: 44, attributes: ["data-openclaw-cdp-ci", "0"] } },
574+
}),
575+
);
576+
return;
577+
}
578+
if (msg.method === "DOM.resolveNode") {
579+
socket.send(JSON.stringify({ id: msg.id, result: { object: { objectId: "link1" } } }));
580+
return;
581+
}
582+
if (msg.method === "Runtime.callFunctionOn") {
583+
socket.send(
584+
JSON.stringify({
585+
id: msg.id,
586+
result: { result: { value: "https://docs.openclaw.ai/" } },
587+
}),
588+
);
589+
}
590+
});
591+
wss = server.wss;
592+
593+
const snap = await snapshotRoleViaCdp({
594+
wsUrl: server.wsUrl,
595+
urls: true,
596+
options: { interactive: true },
597+
});
598+
599+
expect(snap.snapshot).toContain('- button "Save" [ref=e1]');
600+
expect(snap.snapshot).toContain('- link "Docs" [ref=e2] [url=https://docs.openclaw.ai/]');
601+
expect(snap.snapshot).toContain(
602+
'- generic "Clickable Card" [ref=e3] [cursor:pointer, onclick]',
603+
);
604+
expect(snap.refs.e3?.backendDOMNodeId).toBe(44);
605+
});
606+
607+
it("expands one level of iframe snapshots with frame metadata", async () => {
608+
const server = await startMockWsServer((msg, socket) => {
609+
if (
610+
msg.method === "Accessibility.enable" ||
611+
msg.method === "Page.enable" ||
612+
msg.method === "Runtime.evaluate"
613+
) {
614+
socket.send(
615+
JSON.stringify({
616+
id: msg.id,
617+
result: msg.method === "Runtime.evaluate" ? { result: { value: [] } } : {},
618+
}),
619+
);
620+
return;
621+
}
622+
if (msg.method === "Accessibility.getFullAXTree") {
623+
const frameId = msg.params?.frameId;
624+
socket.send(
625+
JSON.stringify({
626+
id: msg.id,
627+
result: {
628+
nodes: frameId
629+
? [
630+
{
631+
nodeId: "c1",
632+
role: { value: "RootWebArea" },
633+
name: { value: "" },
634+
childIds: ["c2"],
635+
},
636+
{
637+
nodeId: "c2",
638+
role: { value: "button" },
639+
name: { value: "Inside" },
640+
backendDOMNodeId: 55,
641+
childIds: [],
642+
},
643+
]
644+
: [
645+
{
646+
nodeId: "1",
647+
role: { value: "RootWebArea" },
648+
name: { value: "" },
649+
childIds: ["2"],
650+
},
651+
{
652+
nodeId: "2",
653+
role: { value: "Iframe" },
654+
name: { value: "Child" },
655+
backendDOMNodeId: 44,
656+
childIds: [],
657+
},
658+
],
659+
},
660+
}),
661+
);
662+
return;
663+
}
664+
if (msg.method === "DOM.describeNode") {
665+
socket.send(
666+
JSON.stringify({
667+
id: msg.id,
668+
result: { node: { contentDocument: { frameId: "FRAME_1" } } },
669+
}),
670+
);
671+
}
672+
});
673+
wss = server.wss;
674+
675+
const snap = await snapshotRoleViaCdp({
676+
wsUrl: server.wsUrl,
677+
options: { interactive: true },
678+
});
679+
680+
expect(snap.snapshot).toContain('- Iframe "Child" [ref=e1]');
681+
expect(snap.snapshot).toContain(' - button "Inside" [ref=e2]');
682+
expect(snap.refs.e1?.frameId).toBe("FRAME_1");
683+
expect(snap.refs.e2?.frameId).toBe("FRAME_1");
684+
});
685+
});
686+
478687
describe("snapshotDom", () => {
479688
it("returns the nodes array from the evaluated expression", async () => {
480689
const server = await startMockWsServer((msg, socket) => {

extensions/browser/src/browser/cdp.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,19 @@ describe("cdp", () => {
4949
params?: Record<string, unknown>;
5050
};
5151
onMessage(msg, socket);
52+
if (msg.method === "Target.attachToTarget") {
53+
socket.send(JSON.stringify({ id: msg.id, result: { sessionId: "S1" } }));
54+
} else if (
55+
msg.method === "Target.detachFromTarget" ||
56+
msg.method === "Page.enable" ||
57+
msg.method === "Runtime.enable" ||
58+
msg.method === "Network.enable" ||
59+
msg.method === "DOM.enable" ||
60+
msg.method === "Accessibility.enable" ||
61+
msg.method === "Runtime.runIfWaitingForDebugger"
62+
) {
63+
socket.send(JSON.stringify({ id: msg.id, result: {} }));
64+
}
5265
});
5366
});
5467
return wsPort;
@@ -87,7 +100,11 @@ describe("cdp", () => {
87100
});
88101

89102
it("creates a target via the browser websocket", async () => {
103+
const methods: string[] = [];
90104
const wsPort = await startWsServerWithMessages((msg, socket) => {
105+
if (msg.method) {
106+
methods.push(msg.method);
107+
}
91108
if (msg.method !== "Target.createTarget") {
92109
return;
93110
}
@@ -109,6 +126,19 @@ describe("cdp", () => {
109126
});
110127

111128
expect(created.targetId).toBe("TARGET_123");
129+
expect(methods).toEqual(
130+
expect.arrayContaining([
131+
"Target.createTarget",
132+
"Target.attachToTarget",
133+
"Page.enable",
134+
"Runtime.enable",
135+
"Network.enable",
136+
"DOM.enable",
137+
"Accessibility.enable",
138+
"Runtime.runIfWaitingForDebugger",
139+
"Target.detachFromTarget",
140+
]),
141+
);
112142
});
113143

114144
it("creates a target via direct WebSocket URL (skips /json/version)", async () => {
@@ -447,6 +477,18 @@ describe("cdp", () => {
447477
};
448478
if (msg.method === "Target.createTarget") {
449479
socket.send(JSON.stringify({ id: msg.id, result: { targetId: "ROOT_FALLBACK" } }));
480+
} else if (msg.method === "Target.attachToTarget") {
481+
socket.send(JSON.stringify({ id: msg.id, result: { sessionId: "S1" } }));
482+
} else if (
483+
msg.method === "Target.detachFromTarget" ||
484+
msg.method === "Page.enable" ||
485+
msg.method === "Runtime.enable" ||
486+
msg.method === "Network.enable" ||
487+
msg.method === "DOM.enable" ||
488+
msg.method === "Accessibility.enable" ||
489+
msg.method === "Runtime.runIfWaitingForDebugger"
490+
) {
491+
socket.send(JSON.stringify({ id: msg.id, result: {} }));
450492
}
451493
});
452494
});

0 commit comments

Comments
 (0)