barebrowse 0.2.0 → 0.2.2
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/CHANGELOG.md +7 -0
- package/README.md +100 -179
- package/mcp-server.js +1 -1
- package/package.json +1 -1
- package/.claude/memory/AGENT_RULES.md +0 -251
- package/.claude/settings.local.json +0 -30
- package/.claude/stash/barebrowse-research-2026-02-22.md +0 -49
- package/.claude/stash/phase3-interactions-complete.md +0 -69
- package/.claude/stash/phase3-prep.md +0 -88
- package/docs/poc-plan.md +0 -230
- package/docs/prd.md +0 -284
- package/examples/headed-demo.js +0 -157
- package/examples/yt-demo.js +0 -137
- package/test/integration/browse.test.js +0 -108
- package/test/integration/interact.test.js +0 -514
- package/test/unit/auth.test.js +0 -66
- package/test/unit/cdp.test.js +0 -110
- package/test/unit/prune.test.js +0 -292
package/test/unit/cdp.test.js
DELETED
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Unit tests for the CDP client + chromium launcher.
|
|
3
|
-
* Requires Chromium installed.
|
|
4
|
-
*
|
|
5
|
-
* Run: node --test test/unit/cdp.test.js
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { describe, it } from 'node:test';
|
|
9
|
-
import assert from 'node:assert/strict';
|
|
10
|
-
import { findBrowser, launch } from '../../src/chromium.js';
|
|
11
|
-
import { createCDP } from '../../src/cdp.js';
|
|
12
|
-
|
|
13
|
-
describe('findBrowser()', () => {
|
|
14
|
-
it('finds a Chromium-based browser', () => {
|
|
15
|
-
const binary = findBrowser();
|
|
16
|
-
assert.ok(binary.length > 0, 'should return a path');
|
|
17
|
-
assert.ok(binary.includes('chrom') || binary.includes('brave') || binary.includes('edge'),
|
|
18
|
-
`${binary} should be a Chromium browser`);
|
|
19
|
-
});
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
describe('launch()', () => {
|
|
23
|
-
it('launches headless Chromium and returns WebSocket URL', async () => {
|
|
24
|
-
const browser = await launch();
|
|
25
|
-
try {
|
|
26
|
-
assert.ok(browser.wsUrl.startsWith('ws://'), 'should return a ws:// URL');
|
|
27
|
-
assert.ok(browser.port > 0, 'should have a port');
|
|
28
|
-
assert.ok(browser.process.pid > 0, 'should have a process');
|
|
29
|
-
} finally {
|
|
30
|
-
browser.process.kill();
|
|
31
|
-
}
|
|
32
|
-
});
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
describe('createCDP()', () => {
|
|
36
|
-
it('connects to browser and sends commands', async () => {
|
|
37
|
-
const browser = await launch();
|
|
38
|
-
try {
|
|
39
|
-
const cdp = await createCDP(browser.wsUrl);
|
|
40
|
-
try {
|
|
41
|
-
// Get browser version — should work on browser-level connection
|
|
42
|
-
const version = await cdp.send('Browser.getVersion');
|
|
43
|
-
assert.ok(version.product.includes('Chrome') || version.product.includes('Headless'),
|
|
44
|
-
`product should be Chrome, got: ${version.product}`);
|
|
45
|
-
} finally {
|
|
46
|
-
cdp.close();
|
|
47
|
-
}
|
|
48
|
-
} finally {
|
|
49
|
-
browser.process.kill();
|
|
50
|
-
}
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it('creates session-scoped handles', async () => {
|
|
54
|
-
const browser = await launch();
|
|
55
|
-
try {
|
|
56
|
-
const cdp = await createCDP(browser.wsUrl);
|
|
57
|
-
try {
|
|
58
|
-
const { targetId } = await cdp.send('Target.createTarget', { url: 'about:blank' });
|
|
59
|
-
const { sessionId } = await cdp.send('Target.attachToTarget', { targetId, flatten: true });
|
|
60
|
-
|
|
61
|
-
const session = cdp.session(sessionId);
|
|
62
|
-
// Enable Page domain on the session
|
|
63
|
-
await session.send('Page.enable');
|
|
64
|
-
|
|
65
|
-
// Navigate — should work on session scope
|
|
66
|
-
await session.send('Page.navigate', { url: 'data:text/html,<h1>hello</h1>' });
|
|
67
|
-
|
|
68
|
-
// Clean up
|
|
69
|
-
await cdp.send('Target.closeTarget', { targetId });
|
|
70
|
-
} finally {
|
|
71
|
-
cdp.close();
|
|
72
|
-
}
|
|
73
|
-
} finally {
|
|
74
|
-
browser.process.kill();
|
|
75
|
-
}
|
|
76
|
-
});
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
describe('ARIA tree via CDP', () => {
|
|
80
|
-
it('gets accessibility tree from a page', async () => {
|
|
81
|
-
const browser = await launch();
|
|
82
|
-
try {
|
|
83
|
-
const cdp = await createCDP(browser.wsUrl);
|
|
84
|
-
try {
|
|
85
|
-
const { targetId } = await cdp.send('Target.createTarget', { url: 'about:blank' });
|
|
86
|
-
const { sessionId } = await cdp.send('Target.attachToTarget', { targetId, flatten: true });
|
|
87
|
-
const session = cdp.session(sessionId);
|
|
88
|
-
|
|
89
|
-
await session.send('Page.enable');
|
|
90
|
-
const loadPromise = session.once('Page.loadEventFired', 10000);
|
|
91
|
-
await session.send('Page.navigate', { url: 'data:text/html,<h1>Test</h1><button>Click</button>' });
|
|
92
|
-
await loadPromise;
|
|
93
|
-
|
|
94
|
-
await session.send('Accessibility.enable');
|
|
95
|
-
const { nodes } = await session.send('Accessibility.getFullAXTree');
|
|
96
|
-
|
|
97
|
-
assert.ok(nodes.length > 0, 'should return ARIA nodes');
|
|
98
|
-
const roles = nodes.map((n) => n.role?.value).filter(Boolean);
|
|
99
|
-
assert.ok(roles.includes('heading'), 'should have heading');
|
|
100
|
-
assert.ok(roles.includes('button'), 'should have button');
|
|
101
|
-
|
|
102
|
-
await cdp.send('Target.closeTarget', { targetId });
|
|
103
|
-
} finally {
|
|
104
|
-
cdp.close();
|
|
105
|
-
}
|
|
106
|
-
} finally {
|
|
107
|
-
browser.process.kill();
|
|
108
|
-
}
|
|
109
|
-
});
|
|
110
|
-
});
|
package/test/unit/prune.test.js
DELETED
|
@@ -1,292 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Unit tests for ARIA tree pruning.
|
|
3
|
-
* No browser needed — pure function tests on tree objects.
|
|
4
|
-
*
|
|
5
|
-
* Run: node --test test/unit/prune.test.js
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { describe, it } from 'node:test';
|
|
9
|
-
import assert from 'node:assert/strict';
|
|
10
|
-
import { prune } from '../../src/prune.js';
|
|
11
|
-
|
|
12
|
-
// Helper: create a minimal ARIA node
|
|
13
|
-
function node(role, name = '', children = [], props = {}) {
|
|
14
|
-
return { nodeId: String(Math.random()).slice(2, 8), role, name, properties: props, ignored: false, children };
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
describe('prune()', () => {
|
|
18
|
-
it('returns null for empty tree', () => {
|
|
19
|
-
assert.equal(prune(null), null);
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
it('unwraps RootWebArea', () => {
|
|
23
|
-
const tree = node('RootWebArea', 'Test Page', [
|
|
24
|
-
node('main', '', [
|
|
25
|
-
node('heading', 'Hello', [], { level: 1 }),
|
|
26
|
-
]),
|
|
27
|
-
]);
|
|
28
|
-
const result = prune(tree);
|
|
29
|
-
// Should not have RootWebArea in output
|
|
30
|
-
assert.notEqual(result.role, 'RootWebArea');
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
it('keeps interactive elements in act mode', () => {
|
|
34
|
-
const tree = node('RootWebArea', '', [
|
|
35
|
-
node('main', '', [
|
|
36
|
-
node('button', 'Click me'),
|
|
37
|
-
node('link', 'Go somewhere'),
|
|
38
|
-
node('textbox', 'Search'),
|
|
39
|
-
]),
|
|
40
|
-
]);
|
|
41
|
-
const result = prune(tree, { mode: 'act' });
|
|
42
|
-
const flat = flattenTree(result);
|
|
43
|
-
const roles = flat.map((n) => n.role);
|
|
44
|
-
assert.ok(roles.includes('button'), 'should keep button');
|
|
45
|
-
assert.ok(roles.includes('link'), 'should keep link');
|
|
46
|
-
assert.ok(roles.includes('textbox'), 'should keep textbox');
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it('drops paragraphs in act mode', () => {
|
|
50
|
-
const tree = node('RootWebArea', '', [
|
|
51
|
-
node('main', '', [
|
|
52
|
-
node('paragraph', '', [
|
|
53
|
-
node('StaticText', 'Some article content'),
|
|
54
|
-
]),
|
|
55
|
-
node('button', 'Submit'),
|
|
56
|
-
]),
|
|
57
|
-
]);
|
|
58
|
-
const result = prune(tree, { mode: 'act' });
|
|
59
|
-
const flat = flattenTree(result);
|
|
60
|
-
const hasP = flat.some((n) => n.role === 'paragraph');
|
|
61
|
-
assert.equal(hasP, false, 'should drop paragraphs in act mode');
|
|
62
|
-
assert.ok(flat.some((n) => n.role === 'button'), 'should keep button');
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
it('keeps paragraphs in browse mode', () => {
|
|
66
|
-
const tree = node('RootWebArea', '', [
|
|
67
|
-
node('main', '', [
|
|
68
|
-
node('paragraph', '', [
|
|
69
|
-
node('StaticText', 'Some article content'),
|
|
70
|
-
]),
|
|
71
|
-
node('button', 'Submit'),
|
|
72
|
-
]),
|
|
73
|
-
]);
|
|
74
|
-
const result = prune(tree, { mode: 'browse' });
|
|
75
|
-
const flat = flattenTree(result);
|
|
76
|
-
assert.ok(flat.some((n) => n.role === 'paragraph'), 'should keep paragraphs in browse mode');
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it('drops InlineTextBox noise', () => {
|
|
80
|
-
const tree = node('RootWebArea', '', [
|
|
81
|
-
node('main', '', [
|
|
82
|
-
node('heading', 'Title', [
|
|
83
|
-
node('StaticText', 'Title', [
|
|
84
|
-
node('InlineTextBox', 'Title'),
|
|
85
|
-
]),
|
|
86
|
-
], { level: 1 }),
|
|
87
|
-
]),
|
|
88
|
-
]);
|
|
89
|
-
const result = prune(tree, { mode: 'browse' });
|
|
90
|
-
const flat = flattenTree(result);
|
|
91
|
-
assert.equal(flat.some((n) => n.role === 'InlineTextBox'), false, 'should drop InlineTextBox');
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
it('keeps headings', () => {
|
|
95
|
-
const tree = node('RootWebArea', '', [
|
|
96
|
-
node('main', '', [
|
|
97
|
-
node('heading', 'Page Title', [], { level: 1 }),
|
|
98
|
-
node('heading', 'Section', [], { level: 2 }),
|
|
99
|
-
]),
|
|
100
|
-
]);
|
|
101
|
-
const result = prune(tree, { mode: 'browse' });
|
|
102
|
-
const flat = flattenTree(result);
|
|
103
|
-
const headings = flat.filter((n) => n.role === 'heading');
|
|
104
|
-
assert.equal(headings.length, 2, 'should keep both headings in browse mode');
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
it('drops description headings in act mode', () => {
|
|
108
|
-
const tree = node('RootWebArea', '', [
|
|
109
|
-
node('main', '', [
|
|
110
|
-
node('heading', 'Page Title', [], { level: 1 }),
|
|
111
|
-
node('heading', 'About this product', [], { level: 2 }),
|
|
112
|
-
node('heading', 'Product details', [], { level: 2 }),
|
|
113
|
-
node('button', 'Buy'),
|
|
114
|
-
]),
|
|
115
|
-
]);
|
|
116
|
-
const result = prune(tree, { mode: 'act' });
|
|
117
|
-
const flat = flattenTree(result);
|
|
118
|
-
const headings = flat.filter((n) => n.role === 'heading');
|
|
119
|
-
// h1 kept, "About this product" and "Product details" dropped
|
|
120
|
-
assert.equal(headings.length, 1, 'should only keep h1 in act mode');
|
|
121
|
-
assert.equal(headings[0].name, 'Page Title');
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
it('collapses unnamed structural wrappers', () => {
|
|
125
|
-
const tree = node('RootWebArea', '', [
|
|
126
|
-
node('main', '', [
|
|
127
|
-
node('generic', '', [
|
|
128
|
-
node('generic', '', [
|
|
129
|
-
node('button', 'Deep button'),
|
|
130
|
-
]),
|
|
131
|
-
]),
|
|
132
|
-
]),
|
|
133
|
-
]);
|
|
134
|
-
const result = prune(tree);
|
|
135
|
-
const flat = flattenTree(result);
|
|
136
|
-
// The nested generics should be collapsed — button should still be there
|
|
137
|
-
assert.ok(flat.some((n) => n.role === 'button' && n.name === 'Deep button'));
|
|
138
|
-
// Generics should be collapsed to _promote or removed
|
|
139
|
-
const generics = flat.filter((n) => n.role === 'generic');
|
|
140
|
-
assert.equal(generics.length, 0, 'generics should be collapsed');
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
it('keeps named groups', () => {
|
|
144
|
-
const tree = node('RootWebArea', '', [
|
|
145
|
-
node('main', '', [
|
|
146
|
-
node('radiogroup', 'Color', [
|
|
147
|
-
node('radio', 'Red'),
|
|
148
|
-
node('radio', 'Blue'),
|
|
149
|
-
]),
|
|
150
|
-
]),
|
|
151
|
-
]);
|
|
152
|
-
const result = prune(tree);
|
|
153
|
-
const flat = flattenTree(result);
|
|
154
|
-
assert.ok(flat.some((n) => n.role === 'radiogroup' && n.name === 'Color'));
|
|
155
|
-
assert.ok(flat.some((n) => n.role === 'radio' && n.name === 'Red'));
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
it('drops separators', () => {
|
|
159
|
-
const tree = node('RootWebArea', '', [
|
|
160
|
-
node('main', '', [
|
|
161
|
-
node('button', 'A'),
|
|
162
|
-
node('separator', ''),
|
|
163
|
-
node('button', 'B'),
|
|
164
|
-
]),
|
|
165
|
-
]);
|
|
166
|
-
const result = prune(tree);
|
|
167
|
-
const flat = flattenTree(result);
|
|
168
|
-
assert.equal(flat.some((n) => n.role === 'separator'), false);
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
it('drops images in act mode, keeps named images in browse', () => {
|
|
172
|
-
const tree = node('RootWebArea', '', [
|
|
173
|
-
node('main', '', [
|
|
174
|
-
node('img', 'Product photo'),
|
|
175
|
-
node('img', ''),
|
|
176
|
-
node('button', 'Buy'),
|
|
177
|
-
]),
|
|
178
|
-
]);
|
|
179
|
-
|
|
180
|
-
const act = prune(tree, { mode: 'act' });
|
|
181
|
-
const actFlat = flattenTree(act);
|
|
182
|
-
assert.equal(actFlat.some((n) => n.role === 'img'), false, 'act: drop all images');
|
|
183
|
-
|
|
184
|
-
const browse = prune(tree, { mode: 'browse' });
|
|
185
|
-
const browseFlat = flattenTree(browse);
|
|
186
|
-
assert.ok(browseFlat.some((n) => n.role === 'img' && n.name === 'Product photo'), 'browse: keep named img');
|
|
187
|
-
// Unnamed image should still be dropped
|
|
188
|
-
const imgs = browseFlat.filter((n) => n.role === 'img');
|
|
189
|
-
assert.equal(imgs.length, 1, 'browse: drop unnamed img');
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
it('trims combobox to just name + selected value', () => {
|
|
193
|
-
const tree = node('RootWebArea', '', [
|
|
194
|
-
node('main', '', [
|
|
195
|
-
node('combobox', 'Size', [
|
|
196
|
-
node('option', 'Small'),
|
|
197
|
-
node('option', 'Medium', [], { selected: true }),
|
|
198
|
-
node('option', 'Large'),
|
|
199
|
-
]),
|
|
200
|
-
]),
|
|
201
|
-
]);
|
|
202
|
-
const result = prune(tree);
|
|
203
|
-
const flat = flattenTree(result);
|
|
204
|
-
const combo = flat.find((n) => n.role === 'combobox');
|
|
205
|
-
assert.ok(combo, 'should keep combobox');
|
|
206
|
-
assert.equal(combo.name, 'Medium', 'should have selected value as name');
|
|
207
|
-
assert.equal(combo.children.length, 0, 'should strip option children');
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
it('uses context keywords to condense non-matching product cards', () => {
|
|
211
|
-
const tree = node('RootWebArea', '', [
|
|
212
|
-
node('listitem', '', [
|
|
213
|
-
node('link', 'iPhone 15 Pro'),
|
|
214
|
-
node('StaticText', '$999'),
|
|
215
|
-
node('button', 'Add to cart'),
|
|
216
|
-
]),
|
|
217
|
-
node('listitem', '', [
|
|
218
|
-
node('link', 'Galaxy S24'),
|
|
219
|
-
node('StaticText', '$799'),
|
|
220
|
-
node('button', 'Add to cart'),
|
|
221
|
-
]),
|
|
222
|
-
]);
|
|
223
|
-
const result = prune(tree, { mode: 'act', context: 'iPhone' });
|
|
224
|
-
const flat = flattenTree(result);
|
|
225
|
-
// iPhone card should be full
|
|
226
|
-
assert.ok(flat.some((n) => n.name === 'iPhone 15 Pro'));
|
|
227
|
-
assert.ok(flat.some((n) => n.name === '$999'));
|
|
228
|
-
// Galaxy card should be condensed (just link, no button/price)
|
|
229
|
-
assert.ok(flat.some((n) => n.name === 'Galaxy S24'), 'condensed card keeps title link');
|
|
230
|
-
// Galaxy's "Add to cart" should be gone
|
|
231
|
-
const galaxyIdx = flat.findIndex((n) => n.name === 'Galaxy S24');
|
|
232
|
-
// After Galaxy, there shouldn't be a button before the next major element
|
|
233
|
-
const afterGalaxy = flat.slice(galaxyIdx + 1);
|
|
234
|
-
const hasGalaxyButton = afterGalaxy.some((n) => n.role === 'button' && n.name === 'Add to cart');
|
|
235
|
-
// This may or may not work depending on exact tree structure — just check Galaxy link exists
|
|
236
|
-
assert.ok(flat.some((n) => n.name === 'Galaxy S24'));
|
|
237
|
-
});
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
describe('prune() landmark extraction', () => {
|
|
241
|
-
it('extracts main landmark when present', () => {
|
|
242
|
-
const tree = node('RootWebArea', '', [
|
|
243
|
-
node('banner', '', [
|
|
244
|
-
node('link', 'Logo'),
|
|
245
|
-
node('navigation', '', [node('link', 'Home')]),
|
|
246
|
-
]),
|
|
247
|
-
node('main', '', [
|
|
248
|
-
node('heading', 'Content', [], { level: 1 }),
|
|
249
|
-
node('button', 'Action'),
|
|
250
|
-
]),
|
|
251
|
-
node('contentinfo', '', [
|
|
252
|
-
node('link', 'Privacy'),
|
|
253
|
-
]),
|
|
254
|
-
]);
|
|
255
|
-
|
|
256
|
-
// Act mode: only main
|
|
257
|
-
const act = prune(tree, { mode: 'act' });
|
|
258
|
-
const actFlat = flattenTree(act);
|
|
259
|
-
assert.ok(actFlat.some((n) => n.name === 'Action'), 'should have main content');
|
|
260
|
-
assert.equal(actFlat.some((n) => n.name === 'Logo'), false, 'should drop banner');
|
|
261
|
-
assert.equal(actFlat.some((n) => n.name === 'Privacy'), false, 'should drop footer');
|
|
262
|
-
|
|
263
|
-
// Navigate mode: main + banner + navigation
|
|
264
|
-
const nav = prune(tree, { mode: 'navigate' });
|
|
265
|
-
const navFlat = flattenTree(nav);
|
|
266
|
-
assert.ok(navFlat.some((n) => n.name === 'Action'), 'nav: should have main');
|
|
267
|
-
assert.ok(navFlat.some((n) => n.name === 'Home'), 'nav: should have navigation links');
|
|
268
|
-
});
|
|
269
|
-
|
|
270
|
-
it('handles pages without landmarks (HN-style)', () => {
|
|
271
|
-
const tree = node('RootWebArea', 'Hacker News', [
|
|
272
|
-
node('link', 'Hacker News'),
|
|
273
|
-
node('link', 'Article 1'),
|
|
274
|
-
node('link', 'Article 2'),
|
|
275
|
-
]);
|
|
276
|
-
const result = prune(tree, { mode: 'act' });
|
|
277
|
-
const flat = flattenTree(result);
|
|
278
|
-
// All links should survive — no landmarks to filter by
|
|
279
|
-
assert.ok(flat.some((n) => n.name === 'Article 1'));
|
|
280
|
-
assert.ok(flat.some((n) => n.name === 'Article 2'));
|
|
281
|
-
});
|
|
282
|
-
});
|
|
283
|
-
|
|
284
|
-
// Helper: flatten tree to array for easy assertions
|
|
285
|
-
function flattenTree(node) {
|
|
286
|
-
if (!node) return [];
|
|
287
|
-
const result = [node];
|
|
288
|
-
for (const child of (node.children || [])) {
|
|
289
|
-
result.push(...flattenTree(child));
|
|
290
|
-
}
|
|
291
|
-
return result;
|
|
292
|
-
}
|