barebrowse 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/memory/AGENT_RULES.md +251 -0
- package/.claude/settings.local.json +30 -0
- package/.claude/stash/barebrowse-research-2026-02-22.md +49 -0
- package/.claude/stash/phase3-interactions-complete.md +69 -0
- package/.claude/stash/phase3-prep.md +88 -0
- package/.mcp.json +8 -0
- package/CHANGELOG.md +93 -0
- package/CLAUDE.md +22 -0
- package/README.md +200 -41
- package/barebrowse.context.md +261 -0
- package/cli.js +156 -0
- package/docs/blueprint.md +361 -0
- package/docs/poc-plan.md +230 -0
- package/docs/prd.md +284 -0
- package/docs/testing.md +202 -0
- package/examples/headed-demo.js +157 -0
- package/examples/yt-demo.js +137 -0
- package/mcp-server.js +216 -0
- package/package.json +22 -9
- package/src/aria.js +69 -0
- package/src/auth.js +279 -0
- package/src/bareagent.js +161 -0
- package/src/cdp.js +148 -0
- package/src/chromium.js +148 -0
- package/src/consent.js +210 -0
- package/src/index.js +186 -10
- package/src/interact.js +208 -0
- package/src/prune.js +472 -0
- package/src/stealth.js +51 -0
- package/test/integration/browse.test.js +108 -0
- package/test/integration/interact.test.js +514 -0
- package/test/unit/auth.test.js +66 -0
- package/test/unit/cdp.test.js +110 -0
- package/test/unit/prune.test.js +292 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* YouTube headed-mode demo — search and play "Family Portrait Pink"
|
|
3
|
+
*
|
|
4
|
+
* SETUP — run this command first in a separate terminal:
|
|
5
|
+
*
|
|
6
|
+
* chromium-browser --remote-debugging-port=9222 \
|
|
7
|
+
* --disable-notifications \
|
|
8
|
+
* --autoplay-policy=no-user-gesture-required \
|
|
9
|
+
* --use-fake-device-for-media-stream \
|
|
10
|
+
* --use-fake-ui-for-media-stream \
|
|
11
|
+
* --disable-features=MediaRouter
|
|
12
|
+
*
|
|
13
|
+
* Then run this script:
|
|
14
|
+
*
|
|
15
|
+
* node examples/yt-demo.js
|
|
16
|
+
*
|
|
17
|
+
* Uses Firefox cookies to bypass YouTube consent wall.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { connect } from '../src/index.js';
|
|
21
|
+
|
|
22
|
+
function wait(ms = 2000) {
|
|
23
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function findRef(snapshot, role, nameFragment) {
|
|
27
|
+
const escaped = nameFragment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
28
|
+
const re = new RegExp(`${role} "[^"]*${escaped}[^"]*".*?\\[ref=([^\\]]+)\\]`, 'i');
|
|
29
|
+
const m = snapshot.match(re);
|
|
30
|
+
return m ? m[1] : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function printSnap(snapshot, limit = 600) {
|
|
34
|
+
const t = snapshot.length > limit
|
|
35
|
+
? snapshot.slice(0, limit) + `\n... (${snapshot.length - limit} more chars)`
|
|
36
|
+
: snapshot;
|
|
37
|
+
console.log(t);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function main() {
|
|
41
|
+
console.log('=== YouTube Demo — Family Portrait by Pink ===\n');
|
|
42
|
+
console.log('Connecting to Chromium on port 9222...\n');
|
|
43
|
+
|
|
44
|
+
const page = await connect({ mode: 'headed', port: 9222 });
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
// Step 1: Inject Firefox cookies for youtube.com (bypasses consent wall)
|
|
48
|
+
console.log('[1] Injecting Firefox cookies for youtube.com...');
|
|
49
|
+
await page.injectCookies('https://www.youtube.com', { browser: 'firefox' });
|
|
50
|
+
await wait(500);
|
|
51
|
+
|
|
52
|
+
// Step 2: Navigate to YouTube
|
|
53
|
+
console.log('[2] Navigating to YouTube...');
|
|
54
|
+
await page.goto('https://www.youtube.com');
|
|
55
|
+
await wait(2000);
|
|
56
|
+
|
|
57
|
+
// Step 3: Find the search box
|
|
58
|
+
console.log('[3] Taking snapshot to find search box...');
|
|
59
|
+
let snap = await page.snapshot();
|
|
60
|
+
|
|
61
|
+
let searchRef = findRef(snap, 'combobox', 'Search')
|
|
62
|
+
|| findRef(snap, 'textbox', 'Search')
|
|
63
|
+
|| findRef(snap, 'searchbox', 'Search');
|
|
64
|
+
|
|
65
|
+
if (!searchRef) {
|
|
66
|
+
console.log(' Could not find search box. Snapshot:');
|
|
67
|
+
printSnap(snap, 1000);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Step 4: Type the search query
|
|
72
|
+
console.log(`[4] Found search box ref=${searchRef}, typing query...`);
|
|
73
|
+
await page.click(searchRef);
|
|
74
|
+
await wait(500);
|
|
75
|
+
await page.type(searchRef, 'Family Portrait Pink', { clear: true });
|
|
76
|
+
await wait(1000);
|
|
77
|
+
|
|
78
|
+
// Step 5: Press Enter to search
|
|
79
|
+
// YouTube is an SPA — loadEventFired won't fire, so just wait for results to render
|
|
80
|
+
console.log('[5] Pressing Enter to search...');
|
|
81
|
+
await page.press('Enter');
|
|
82
|
+
await wait(3000);
|
|
83
|
+
|
|
84
|
+
// Step 6: Find the video in results
|
|
85
|
+
console.log('[6] Looking for Family Portrait in results...');
|
|
86
|
+
snap = await page.snapshot();
|
|
87
|
+
|
|
88
|
+
let videoRef = findRef(snap, 'link', 'Family Portrait')
|
|
89
|
+
|| findRef(snap, 'link', 'family portrait');
|
|
90
|
+
|
|
91
|
+
if (!videoRef) {
|
|
92
|
+
console.log(' Could not find video link. Trying broader match...');
|
|
93
|
+
printSnap(snap, 1500);
|
|
94
|
+
// Try any link with "Pink" in it
|
|
95
|
+
videoRef = findRef(snap, 'link', 'Pink');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!videoRef) {
|
|
99
|
+
console.log(' No matching video found.');
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Step 7: Click the video (SPA nav — no loadEventFired)
|
|
104
|
+
console.log(`[7] Found video ref=${videoRef}, clicking to play...`);
|
|
105
|
+
await page.click(videoRef);
|
|
106
|
+
await wait(4000);
|
|
107
|
+
|
|
108
|
+
// Step 8: Snapshot the video page
|
|
109
|
+
console.log('[8] Video page snapshot:\n');
|
|
110
|
+
snap = await page.snapshot();
|
|
111
|
+
printSnap(snap, 800);
|
|
112
|
+
|
|
113
|
+
console.log('\n=== Video should be playing! ===');
|
|
114
|
+
console.log('Press Ctrl+C to exit when done watching.\n');
|
|
115
|
+
|
|
116
|
+
// Keep alive so user can watch
|
|
117
|
+
await new Promise(() => {});
|
|
118
|
+
} finally {
|
|
119
|
+
await page.close();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
main().catch((err) => {
|
|
124
|
+
if (err.message?.includes('ECONNREFUSED')) {
|
|
125
|
+
console.error('\nError: Could not connect to Chromium on port 9222.');
|
|
126
|
+
console.error('Start Chromium first:\n');
|
|
127
|
+
console.error(' chromium-browser --remote-debugging-port=9222 \\');
|
|
128
|
+
console.error(' --disable-notifications \\');
|
|
129
|
+
console.error(' --autoplay-policy=no-user-gesture-required \\');
|
|
130
|
+
console.error(' --use-fake-device-for-media-stream \\');
|
|
131
|
+
console.error(' --use-fake-ui-for-media-stream \\');
|
|
132
|
+
console.error(' --disable-features=MediaRouter\n');
|
|
133
|
+
} else {
|
|
134
|
+
console.error('\nError:', err.message || err);
|
|
135
|
+
}
|
|
136
|
+
process.exit(1);
|
|
137
|
+
});
|
package/mcp-server.js
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* mcp-server.js — MCP server for barebrowse.
|
|
4
|
+
*
|
|
5
|
+
* Raw JSON-RPC 2.0 over stdio. No SDK dependency.
|
|
6
|
+
* 7 tools: browse (one-shot), goto, snapshot, click, type, press, scroll.
|
|
7
|
+
*
|
|
8
|
+
* Session tools share a singleton page, lazy-created on first use.
|
|
9
|
+
* Action tools return 'ok' — agent calls snapshot explicitly to observe.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { browse, connect } from './src/index.js';
|
|
13
|
+
|
|
14
|
+
let _page = null;
|
|
15
|
+
|
|
16
|
+
async function getPage() {
|
|
17
|
+
if (!_page) _page = await connect();
|
|
18
|
+
return _page;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const TOOLS = [
|
|
22
|
+
{
|
|
23
|
+
name: 'browse',
|
|
24
|
+
description: 'One-shot: navigate to a URL and return a pruned ARIA snapshot. Stateless — does not use the session page.',
|
|
25
|
+
inputSchema: {
|
|
26
|
+
type: 'object',
|
|
27
|
+
properties: {
|
|
28
|
+
url: { type: 'string', description: 'URL to browse' },
|
|
29
|
+
mode: { type: 'string', enum: ['headless', 'headed', 'hybrid'], description: 'Browser mode (default: headless)' },
|
|
30
|
+
},
|
|
31
|
+
required: ['url'],
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: 'goto',
|
|
36
|
+
description: 'Navigate the session page to a URL. Returns ok — call snapshot to observe.',
|
|
37
|
+
inputSchema: {
|
|
38
|
+
type: 'object',
|
|
39
|
+
properties: {
|
|
40
|
+
url: { type: 'string', description: 'URL to navigate to' },
|
|
41
|
+
},
|
|
42
|
+
required: ['url'],
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: 'snapshot',
|
|
47
|
+
description: 'Get the current ARIA snapshot of the session page. Returns a YAML-like tree with [ref=N] markers on interactive elements.',
|
|
48
|
+
inputSchema: { type: 'object', properties: {} },
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: 'click',
|
|
52
|
+
description: 'Click an element by its ref from the snapshot. Returns ok — call snapshot to observe.',
|
|
53
|
+
inputSchema: {
|
|
54
|
+
type: 'object',
|
|
55
|
+
properties: {
|
|
56
|
+
ref: { type: 'string', description: 'Element ref from snapshot (e.g. "8")' },
|
|
57
|
+
},
|
|
58
|
+
required: ['ref'],
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name: 'type',
|
|
63
|
+
description: 'Type text into an element by its ref. Returns ok — call snapshot to observe.',
|
|
64
|
+
inputSchema: {
|
|
65
|
+
type: 'object',
|
|
66
|
+
properties: {
|
|
67
|
+
ref: { type: 'string', description: 'Element ref from snapshot' },
|
|
68
|
+
text: { type: 'string', description: 'Text to type' },
|
|
69
|
+
clear: { type: 'boolean', description: 'Clear existing content first (default: false)' },
|
|
70
|
+
},
|
|
71
|
+
required: ['ref', 'text'],
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
name: 'press',
|
|
76
|
+
description: 'Press a special key (Enter, Tab, Escape, Backspace, Delete, ArrowUp/Down/Left/Right, Home, End, PageUp, PageDown, Space). Returns ok.',
|
|
77
|
+
inputSchema: {
|
|
78
|
+
type: 'object',
|
|
79
|
+
properties: {
|
|
80
|
+
key: { type: 'string', description: 'Key name' },
|
|
81
|
+
},
|
|
82
|
+
required: ['key'],
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
name: 'scroll',
|
|
87
|
+
description: 'Scroll the page. Positive deltaY scrolls down, negative scrolls up. Returns ok.',
|
|
88
|
+
inputSchema: {
|
|
89
|
+
type: 'object',
|
|
90
|
+
properties: {
|
|
91
|
+
deltaY: { type: 'number', description: 'Pixels to scroll (positive=down, negative=up)' },
|
|
92
|
+
},
|
|
93
|
+
required: ['deltaY'],
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
async function handleToolCall(name, args) {
|
|
99
|
+
switch (name) {
|
|
100
|
+
case 'browse':
|
|
101
|
+
return await browse(args.url, { mode: args.mode });
|
|
102
|
+
|
|
103
|
+
case 'goto': {
|
|
104
|
+
const page = await getPage();
|
|
105
|
+
await page.goto(args.url);
|
|
106
|
+
return 'ok';
|
|
107
|
+
}
|
|
108
|
+
case 'snapshot': {
|
|
109
|
+
const page = await getPage();
|
|
110
|
+
return await page.snapshot();
|
|
111
|
+
}
|
|
112
|
+
case 'click': {
|
|
113
|
+
const page = await getPage();
|
|
114
|
+
await page.click(args.ref);
|
|
115
|
+
return 'ok';
|
|
116
|
+
}
|
|
117
|
+
case 'type': {
|
|
118
|
+
const page = await getPage();
|
|
119
|
+
await page.type(args.ref, args.text, { clear: args.clear });
|
|
120
|
+
return 'ok';
|
|
121
|
+
}
|
|
122
|
+
case 'press': {
|
|
123
|
+
const page = await getPage();
|
|
124
|
+
await page.press(args.key);
|
|
125
|
+
return 'ok';
|
|
126
|
+
}
|
|
127
|
+
case 'scroll': {
|
|
128
|
+
const page = await getPage();
|
|
129
|
+
await page.scroll(args.deltaY);
|
|
130
|
+
return 'ok';
|
|
131
|
+
}
|
|
132
|
+
default:
|
|
133
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function jsonrpcResponse(id, result) {
|
|
138
|
+
return JSON.stringify({ jsonrpc: '2.0', id, result });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function jsonrpcError(id, code, message) {
|
|
142
|
+
return JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function handleMessage(msg) {
|
|
146
|
+
const { id, method, params } = msg;
|
|
147
|
+
|
|
148
|
+
if (method === 'initialize') {
|
|
149
|
+
return jsonrpcResponse(id, {
|
|
150
|
+
protocolVersion: '2024-11-05',
|
|
151
|
+
capabilities: { tools: {} },
|
|
152
|
+
serverInfo: { name: 'barebrowse', version: '0.2.0' },
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (method === 'notifications/initialized') {
|
|
157
|
+
return null; // notification, no response
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (method === 'tools/list') {
|
|
161
|
+
return jsonrpcResponse(id, { tools: TOOLS });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (method === 'tools/call') {
|
|
165
|
+
const { name, arguments: args } = params;
|
|
166
|
+
try {
|
|
167
|
+
const result = await handleToolCall(name, args || {});
|
|
168
|
+
return jsonrpcResponse(id, {
|
|
169
|
+
content: [{ type: 'text', text: typeof result === 'string' ? result : JSON.stringify(result) }],
|
|
170
|
+
});
|
|
171
|
+
} catch (err) {
|
|
172
|
+
return jsonrpcResponse(id, {
|
|
173
|
+
content: [{ type: 'text', text: `Error: ${err.message}` }],
|
|
174
|
+
isError: true,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return jsonrpcError(id, -32601, `Method not found: ${method}`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// --- Stdio transport ---
|
|
183
|
+
|
|
184
|
+
let buffer = '';
|
|
185
|
+
|
|
186
|
+
process.stdin.setEncoding('utf8');
|
|
187
|
+
process.stdin.on('data', async (chunk) => {
|
|
188
|
+
buffer += chunk;
|
|
189
|
+
let newlineIdx;
|
|
190
|
+
while ((newlineIdx = buffer.indexOf('\n')) !== -1) {
|
|
191
|
+
const line = buffer.slice(0, newlineIdx).trim();
|
|
192
|
+
buffer = buffer.slice(newlineIdx + 1);
|
|
193
|
+
if (!line) continue;
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
const msg = JSON.parse(line);
|
|
197
|
+
const response = await handleMessage(msg);
|
|
198
|
+
if (response) {
|
|
199
|
+
process.stdout.write(response + '\n');
|
|
200
|
+
}
|
|
201
|
+
} catch (err) {
|
|
202
|
+
process.stdout.write(jsonrpcError(null, -32700, `Parse error: ${err.message}`) + '\n');
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// Clean up on exit
|
|
208
|
+
process.on('SIGINT', async () => {
|
|
209
|
+
if (_page) await _page.close().catch(() => {});
|
|
210
|
+
process.exit(0);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
process.on('SIGTERM', async () => {
|
|
214
|
+
if (_page) await _page.close().catch(() => {});
|
|
215
|
+
process.exit(0);
|
|
216
|
+
});
|
package/package.json
CHANGED
|
@@ -1,19 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "barebrowse",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Authenticated web browsing for autonomous agents via CDP",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Authenticated web browsing for autonomous agents via CDP. URL in, pruned ARIA snapshot out.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
7
|
-
"exports":
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js",
|
|
9
|
+
"./bareagent": "./src/bareagent.js"
|
|
10
|
+
},
|
|
11
|
+
"bin": {
|
|
12
|
+
"barebrowse": "./cli.js"
|
|
13
|
+
},
|
|
8
14
|
"engines": {
|
|
9
15
|
"node": ">=22"
|
|
10
16
|
},
|
|
11
17
|
"scripts": {
|
|
12
|
-
"test": "node --test test
|
|
18
|
+
"test": "node --test test/unit/*.test.js test/integration/*.test.js"
|
|
13
19
|
},
|
|
14
|
-
"
|
|
15
|
-
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
|
|
20
|
+
"keywords": [
|
|
21
|
+
"browser",
|
|
22
|
+
"cdp",
|
|
23
|
+
"chromium",
|
|
24
|
+
"aria",
|
|
25
|
+
"accessibility",
|
|
26
|
+
"web-scraping",
|
|
27
|
+
"agent",
|
|
28
|
+
"mcp",
|
|
29
|
+
"headless"
|
|
30
|
+
],
|
|
31
|
+
"license": "MIT"
|
|
19
32
|
}
|
package/src/aria.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* aria.js — Format ARIA accessibility tree nodes for agent consumption.
|
|
3
|
+
*
|
|
4
|
+
* Takes a nested tree (built from CDP's Accessibility.getFullAXTree)
|
|
5
|
+
* and formats it as readable YAML-like text, similar to Playwright's ariaSnapshot.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Format a nested ARIA tree as readable text output.
|
|
10
|
+
*
|
|
11
|
+
* Output format (one node per line, indented):
|
|
12
|
+
* - role "name" [props] [ref=nodeId]
|
|
13
|
+
*
|
|
14
|
+
* @param {object} node - Tree node { role, name, properties, children, ignored, nodeId }
|
|
15
|
+
* @param {number} [depth=0] - Current indentation depth
|
|
16
|
+
* @returns {string} Formatted ARIA tree text
|
|
17
|
+
*/
|
|
18
|
+
export function formatTree(node, depth = 0) {
|
|
19
|
+
if (!node) return '';
|
|
20
|
+
|
|
21
|
+
// Skip ignored nodes but still process their children
|
|
22
|
+
if (node.ignored) {
|
|
23
|
+
return node.children.map((c) => formatTree(c, depth)).join('');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Skip low-level rendering nodes that are noise for agents
|
|
27
|
+
const SKIP_ROLES = new Set(['InlineTextBox', 'LineBreak']);
|
|
28
|
+
if (SKIP_ROLES.has(node.role)) return '';
|
|
29
|
+
|
|
30
|
+
const indent = ' '.repeat(depth);
|
|
31
|
+
const lines = [];
|
|
32
|
+
|
|
33
|
+
// Build line: "- role "name" [properties] [ref=id]"
|
|
34
|
+
let line = `${indent}- ${node.role || 'none'}`;
|
|
35
|
+
|
|
36
|
+
if (node.name) {
|
|
37
|
+
line += ` "${node.name}"`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Notable properties that agents care about
|
|
41
|
+
const props = node.properties || {};
|
|
42
|
+
const propParts = [];
|
|
43
|
+
if (props.checked !== undefined) propParts.push(`checked=${props.checked}`);
|
|
44
|
+
if (props.disabled) propParts.push('disabled');
|
|
45
|
+
if (props.expanded !== undefined) propParts.push(`expanded=${props.expanded}`);
|
|
46
|
+
if (props.level) propParts.push(`level=${props.level}`);
|
|
47
|
+
if (props.selected) propParts.push('selected');
|
|
48
|
+
if (props.required) propParts.push('required');
|
|
49
|
+
if (props.value !== undefined && props.value !== '') propParts.push(`value="${props.value}"`);
|
|
50
|
+
|
|
51
|
+
if (propParts.length > 0) {
|
|
52
|
+
line += ` [${propParts.join(', ')}]`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Node ID as ref — agents use this to target interactions
|
|
56
|
+
if (node.nodeId) {
|
|
57
|
+
line += ` [ref=${node.nodeId}]`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
lines.push(line);
|
|
61
|
+
|
|
62
|
+
// Recurse into children
|
|
63
|
+
for (const child of node.children) {
|
|
64
|
+
const childText = formatTree(child, depth + 1);
|
|
65
|
+
if (childText) lines.push(childText);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return lines.join('\n');
|
|
69
|
+
}
|