barebrowse 0.2.0 → 0.2.1
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
|
@@ -1,108 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Integration tests for the browse() pipeline.
|
|
3
|
-
* Requires Chromium installed: sudo dnf install chromium
|
|
4
|
-
*
|
|
5
|
-
* Run: node --test test/integration/browse.test.js
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { describe, it } from 'node:test';
|
|
9
|
-
import assert from 'node:assert/strict';
|
|
10
|
-
import { browse, connect } from '../../src/index.js';
|
|
11
|
-
|
|
12
|
-
describe('browse()', () => {
|
|
13
|
-
it('returns ARIA snapshot for a public page', async () => {
|
|
14
|
-
const snapshot = await browse('https://example.com');
|
|
15
|
-
assert.ok(snapshot.length > 0, 'snapshot should not be empty');
|
|
16
|
-
assert.ok(snapshot.includes('Example Domain'), 'should contain page title');
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
it('includes heading and ref markers', async () => {
|
|
20
|
-
const snapshot = await browse('https://example.com');
|
|
21
|
-
assert.ok(snapshot.includes('heading'), 'should have heading role');
|
|
22
|
-
assert.ok(snapshot.includes('[ref='), 'should have ref markers for interaction');
|
|
23
|
-
// In browse mode, links should also be present
|
|
24
|
-
const browseSnap = await browse('https://example.com', { pruneMode: 'browse' });
|
|
25
|
-
assert.ok(browseSnap.includes('link'), 'browse mode should have link role');
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
it('prunes by default (act mode)', async () => {
|
|
29
|
-
const pruned = await browse('https://example.com');
|
|
30
|
-
const raw = await browse('https://example.com', { prune: false });
|
|
31
|
-
// Pruned should be smaller or equal (example.com is tiny, may not differ much)
|
|
32
|
-
assert.ok(pruned.length <= raw.length, 'pruned should not be larger than raw');
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
it('browse mode preserves paragraphs', async () => {
|
|
36
|
-
const snapshot = await browse('https://example.com', { pruneMode: 'browse' });
|
|
37
|
-
assert.ok(snapshot.includes('paragraph'), 'browse mode should keep paragraphs');
|
|
38
|
-
assert.ok(snapshot.includes('documentation examples'), 'should keep paragraph text');
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
it('act mode drops paragraphs', async () => {
|
|
42
|
-
const snapshot = await browse('https://example.com', { pruneMode: 'act' });
|
|
43
|
-
// Act mode on example.com: only heading survives (no interactive elements)
|
|
44
|
-
assert.ok(snapshot.includes('heading'), 'should keep heading');
|
|
45
|
-
// paragraph content should be gone
|
|
46
|
-
assert.equal(snapshot.includes('documentation examples'), false, 'should drop paragraph text');
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it('handles complex pages with significant token reduction', async () => {
|
|
50
|
-
const pruned = await browse('https://news.ycombinator.com');
|
|
51
|
-
const raw = await browse('https://news.ycombinator.com', { prune: false });
|
|
52
|
-
const reduction = 1 - (pruned.length / raw.length);
|
|
53
|
-
console.log(` HN reduction: ${Math.round(reduction * 100)}% (${raw.length} → ${pruned.length})`);
|
|
54
|
-
assert.ok(reduction > 0.2, `should reduce by at least 20%, got ${Math.round(reduction * 100)}%`);
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it('can disable cookies', async () => {
|
|
58
|
-
// Should not throw even with cookies: false
|
|
59
|
-
const snapshot = await browse('https://example.com', { cookies: false });
|
|
60
|
-
assert.ok(snapshot.includes('Example Domain'));
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it('can disable pruning', async () => {
|
|
64
|
-
const snapshot = await browse('https://example.com', { prune: false });
|
|
65
|
-
assert.ok(snapshot.includes('Example Domain'));
|
|
66
|
-
// Raw output should have InlineTextBox filtered by aria.js but not tree-pruned
|
|
67
|
-
assert.ok(snapshot.includes('RootWebArea'), 'raw should keep RootWebArea');
|
|
68
|
-
});
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
describe('connect()', () => {
|
|
72
|
-
it('creates a long-lived session and navigates', async () => {
|
|
73
|
-
const page = await connect();
|
|
74
|
-
try {
|
|
75
|
-
await page.goto('https://example.com');
|
|
76
|
-
const snapshot = await page.snapshot();
|
|
77
|
-
assert.ok(snapshot.includes('Example Domain'), 'should see page content');
|
|
78
|
-
} finally {
|
|
79
|
-
await page.close();
|
|
80
|
-
}
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
it('supports multiple navigations in one session', async () => {
|
|
84
|
-
const page = await connect();
|
|
85
|
-
try {
|
|
86
|
-
await page.goto('https://example.com');
|
|
87
|
-
const snap1 = await page.snapshot();
|
|
88
|
-
assert.ok(snap1.includes('Example Domain'));
|
|
89
|
-
|
|
90
|
-
await page.goto('https://news.ycombinator.com');
|
|
91
|
-
const snap2 = await page.snapshot();
|
|
92
|
-
assert.ok(snap2.includes('Hacker News'));
|
|
93
|
-
} finally {
|
|
94
|
-
await page.close();
|
|
95
|
-
}
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
it('snapshot accepts prune: false for raw output', async () => {
|
|
99
|
-
const page = await connect();
|
|
100
|
-
try {
|
|
101
|
-
await page.goto('https://example.com');
|
|
102
|
-
const raw = await page.snapshot(false);
|
|
103
|
-
assert.ok(raw.includes('RootWebArea'), 'raw should keep RootWebArea');
|
|
104
|
-
} finally {
|
|
105
|
-
await page.close();
|
|
106
|
-
}
|
|
107
|
-
});
|
|
108
|
-
});
|
|
@@ -1,514 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Integration tests for interaction primitives (click, type, press, scroll).
|
|
3
|
-
* Uses data: URL fixtures for deterministic testing + real sites for validation.
|
|
4
|
-
*
|
|
5
|
-
* Run: node --test test/integration/interact.test.js
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { describe, it } from 'node:test';
|
|
9
|
-
import assert from 'node:assert/strict';
|
|
10
|
-
import { connect } from '../../src/index.js';
|
|
11
|
-
import { extractCookies, injectCookies } from '../../src/auth.js';
|
|
12
|
-
|
|
13
|
-
// --- Data URL fixtures ---
|
|
14
|
-
|
|
15
|
-
const FIXTURE = `data:text/html,${encodeURIComponent(`<!DOCTYPE html>
|
|
16
|
-
<html><head><style>
|
|
17
|
-
.offscreen { margin-top: 2000px; }
|
|
18
|
-
</style></head>
|
|
19
|
-
<body>
|
|
20
|
-
<button id="btn" onclick="document.getElementById('result').textContent='clicked'">Click Me</button>
|
|
21
|
-
<div id="result"></div>
|
|
22
|
-
|
|
23
|
-
<input id="text-input" type="text" aria-label="empty-input" value="" />
|
|
24
|
-
<input id="prefilled" type="text" aria-label="prefilled-input" value="old text" />
|
|
25
|
-
|
|
26
|
-
<a id="nav-link" href="data:text/html,<h1>Page Two</h1>">Go to page two</a>
|
|
27
|
-
|
|
28
|
-
<button id="offscreen-btn" class="offscreen"
|
|
29
|
-
onclick="document.getElementById('offscreen-result').textContent='scrolled-and-clicked'">
|
|
30
|
-
Offscreen Button
|
|
31
|
-
</button>
|
|
32
|
-
<div id="offscreen-result" class="offscreen"></div>
|
|
33
|
-
|
|
34
|
-
<form id="form" onsubmit="event.preventDefault(); document.getElementById('form-result').textContent='submitted'">
|
|
35
|
-
<input id="form-input" type="text" aria-label="form-input" />
|
|
36
|
-
<div id="form-result"></div>
|
|
37
|
-
</form>
|
|
38
|
-
</body></html>`)}`;
|
|
39
|
-
|
|
40
|
-
/** Helper: evaluate JS in the page and return the result. */
|
|
41
|
-
async function evaluate(page, expression) {
|
|
42
|
-
const { result } = await page.cdp.send('Runtime.evaluate', { expression, returnByValue: true });
|
|
43
|
-
return result.value;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Find a ref by matching a pattern against the full snapshot text.
|
|
48
|
-
* Uses regex to find [ref=X] near the search term, handling concatenated lines.
|
|
49
|
-
* Matches role "name" [ref=X] pattern closest to the search term.
|
|
50
|
-
*/
|
|
51
|
-
function findRef(snapshot, search) {
|
|
52
|
-
// Find all occurrences of the search term with a nearby ref
|
|
53
|
-
// Pattern: something "...search..." [ref=X] or search... [ref=X]
|
|
54
|
-
const escaped = search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
55
|
-
// Look for ref right after a chunk containing the search term
|
|
56
|
-
const re = new RegExp(`${escaped}[^\\[]*?\\[ref=([^\\]]+)\\]`);
|
|
57
|
-
const m = snapshot.match(re);
|
|
58
|
-
if (m) return m[1];
|
|
59
|
-
// Fallback: look for ref just before the search term
|
|
60
|
-
const re2 = new RegExp(`\\[ref=([^\\]]+)\\][^\\n]*?${escaped}`);
|
|
61
|
-
const m2 = snapshot.match(re2);
|
|
62
|
-
return m2 ? m2[1] : null;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Find a ref for a specific role+name combo, e.g. findRoleRef(snap, 'button', 'Click Me').
|
|
67
|
-
*/
|
|
68
|
-
function findRoleRef(snapshot, role, name) {
|
|
69
|
-
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
70
|
-
const re = new RegExp(`${role} "${escaped}" \\[ref=([^\\]]+)\\]`);
|
|
71
|
-
const m = snapshot.match(re);
|
|
72
|
-
return m ? m[1] : null;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Find a ref for a textbox by its aria-label name.
|
|
77
|
-
*/
|
|
78
|
-
function findTextboxRef(snapshot, name) {
|
|
79
|
-
return findRoleRef(snapshot, 'textbox', name);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// ===== Round 1: Controlled fixture (data: URL) =====
|
|
83
|
-
|
|
84
|
-
describe('interact — data: URL fixture', () => {
|
|
85
|
-
it('click sets button result text', async () => {
|
|
86
|
-
const page = await connect();
|
|
87
|
-
try {
|
|
88
|
-
await page.goto(FIXTURE);
|
|
89
|
-
const snap = await page.snapshot();
|
|
90
|
-
const ref = findRoleRef(snap, 'button', 'Click Me');
|
|
91
|
-
assert.ok(ref, 'should find Click Me button ref');
|
|
92
|
-
await page.click(ref);
|
|
93
|
-
await new Promise(r => setTimeout(r, 100));
|
|
94
|
-
const result = await evaluate(page, 'document.getElementById("result").textContent');
|
|
95
|
-
assert.equal(result, 'clicked');
|
|
96
|
-
} finally {
|
|
97
|
-
await page.close();
|
|
98
|
-
}
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
it('type fills an empty input', async () => {
|
|
102
|
-
const page = await connect();
|
|
103
|
-
try {
|
|
104
|
-
await page.goto(FIXTURE);
|
|
105
|
-
const snap = await page.snapshot();
|
|
106
|
-
const ref = findTextboxRef(snap, 'empty-input');
|
|
107
|
-
assert.ok(ref, 'should find empty-input textbox ref');
|
|
108
|
-
await page.type(ref, 'hello world');
|
|
109
|
-
const value = await evaluate(page, 'document.getElementById("text-input").value');
|
|
110
|
-
assert.equal(value, 'hello world');
|
|
111
|
-
} finally {
|
|
112
|
-
await page.close();
|
|
113
|
-
}
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
it('type with clear replaces existing text', async () => {
|
|
117
|
-
const page = await connect();
|
|
118
|
-
try {
|
|
119
|
-
await page.goto(FIXTURE);
|
|
120
|
-
const snap = await page.snapshot();
|
|
121
|
-
const ref = findTextboxRef(snap, 'prefilled-input');
|
|
122
|
-
assert.ok(ref, 'should find prefilled-input textbox ref');
|
|
123
|
-
await page.type(ref, 'new text', { clear: true });
|
|
124
|
-
const value = await evaluate(page, 'document.getElementById("prefilled").value');
|
|
125
|
-
assert.equal(value, 'new text');
|
|
126
|
-
} finally {
|
|
127
|
-
await page.close();
|
|
128
|
-
}
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
it('click on offscreen element scrolls into view first', async () => {
|
|
132
|
-
const page = await connect();
|
|
133
|
-
try {
|
|
134
|
-
await page.goto(FIXTURE);
|
|
135
|
-
const snap = await page.snapshot();
|
|
136
|
-
const ref = findRoleRef(snap, 'button', 'Offscreen Button');
|
|
137
|
-
assert.ok(ref, 'should find offscreen button ref');
|
|
138
|
-
await page.click(ref);
|
|
139
|
-
await new Promise(r => setTimeout(r, 100));
|
|
140
|
-
const result = await evaluate(page, 'document.getElementById("offscreen-result").textContent');
|
|
141
|
-
assert.equal(result, 'scrolled-and-clicked');
|
|
142
|
-
} finally {
|
|
143
|
-
await page.close();
|
|
144
|
-
}
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
it('press Enter submits a form', async () => {
|
|
148
|
-
const page = await connect();
|
|
149
|
-
try {
|
|
150
|
-
await page.goto(FIXTURE);
|
|
151
|
-
const snap = await page.snapshot();
|
|
152
|
-
const ref = findTextboxRef(snap, 'form-input');
|
|
153
|
-
assert.ok(ref, 'should find form-input textbox ref');
|
|
154
|
-
await page.type(ref, 'test');
|
|
155
|
-
await page.press('Enter');
|
|
156
|
-
await new Promise(r => setTimeout(r, 100));
|
|
157
|
-
const result = await evaluate(page, 'document.getElementById("form-result").textContent');
|
|
158
|
-
assert.equal(result, 'submitted');
|
|
159
|
-
} finally {
|
|
160
|
-
await page.close();
|
|
161
|
-
}
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
it('press throws on unknown key', async () => {
|
|
165
|
-
const page = await connect();
|
|
166
|
-
try {
|
|
167
|
-
await page.goto(FIXTURE);
|
|
168
|
-
await assert.rejects(() => page.press('FakeKey'), /Unknown key/);
|
|
169
|
-
} finally {
|
|
170
|
-
await page.close();
|
|
171
|
-
}
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
it('link click + waitForNavigation navigates to new page', async () => {
|
|
175
|
-
// Use example.com with browse mode to get link refs (act mode prunes them)
|
|
176
|
-
const page = await connect();
|
|
177
|
-
try {
|
|
178
|
-
await page.goto('https://example.com');
|
|
179
|
-
const snap = await page.snapshot({ mode: 'browse' });
|
|
180
|
-
const ref = findRoleRef(snap, 'link', 'Learn more');
|
|
181
|
-
assert.ok(ref, 'should find "Learn more" link ref');
|
|
182
|
-
const navPromise = page.waitForNavigation(10000);
|
|
183
|
-
await page.click(ref);
|
|
184
|
-
await navPromise;
|
|
185
|
-
const snap2 = await page.snapshot({ mode: 'browse' });
|
|
186
|
-
// IANA page has different content than example.com (e.g. "IANA" or navigation elements)
|
|
187
|
-
assert.ok(snap2.length > 100, 'new page should have content');
|
|
188
|
-
assert.ok(
|
|
189
|
-
snap2.includes('IANA') || snap2.includes('iana') || !snap2.includes('This domain is for use'),
|
|
190
|
-
'should have navigated to IANA page',
|
|
191
|
-
);
|
|
192
|
-
} finally {
|
|
193
|
-
await page.close();
|
|
194
|
-
}
|
|
195
|
-
});
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
// ===== Round 2: Google Search =====
|
|
199
|
-
|
|
200
|
-
describe('interact — Google Search', () => {
|
|
201
|
-
it('search and navigate results', async () => {
|
|
202
|
-
const page = await connect();
|
|
203
|
-
try {
|
|
204
|
-
await page.goto('https://www.google.com');
|
|
205
|
-
let snap = await page.snapshot();
|
|
206
|
-
|
|
207
|
-
// Handle cookie consent dialog if present
|
|
208
|
-
const acceptRef = findRoleRef(snap, 'button', 'Accept all')
|
|
209
|
-
|| findRoleRef(snap, 'button', 'Alles accepteren')
|
|
210
|
-
|| findRoleRef(snap, 'button', 'Tout accepter')
|
|
211
|
-
|| findRoleRef(snap, 'button', 'Alle akzeptieren');
|
|
212
|
-
if (acceptRef) {
|
|
213
|
-
await page.click(acceptRef);
|
|
214
|
-
// Consent acceptance may reload the page
|
|
215
|
-
await page.waitForNavigation(5000).catch(() => {});
|
|
216
|
-
await new Promise(r => setTimeout(r, 1000));
|
|
217
|
-
snap = await page.snapshot();
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
// Find the search box — could be combobox or textbox, various names
|
|
221
|
-
let searchRef = null;
|
|
222
|
-
const searchPattern = /(?:combobox|textbox).*?\[ref=([^\]]+)\]/g;
|
|
223
|
-
let m;
|
|
224
|
-
while ((m = searchPattern.exec(snap)) !== null) {
|
|
225
|
-
searchRef = m[1];
|
|
226
|
-
break;
|
|
227
|
-
}
|
|
228
|
-
assert.ok(searchRef, `should find search box ref. Snapshot start: ${snap.substring(0, 500)}`);
|
|
229
|
-
|
|
230
|
-
await page.type(searchRef, 'barebrowse github');
|
|
231
|
-
const navPromise = page.waitForNavigation(15000);
|
|
232
|
-
await page.press('Enter');
|
|
233
|
-
await navPromise;
|
|
234
|
-
// Settle time for results to render
|
|
235
|
-
await new Promise(r => setTimeout(r, 1000));
|
|
236
|
-
|
|
237
|
-
snap = await page.snapshot();
|
|
238
|
-
// Google may block headless browsers with captcha, but navigation should succeed
|
|
239
|
-
assert.ok(snap.length > 0, 'should have navigated to results/captcha page');
|
|
240
|
-
} finally {
|
|
241
|
-
await page.close();
|
|
242
|
-
}
|
|
243
|
-
});
|
|
244
|
-
});
|
|
245
|
-
|
|
246
|
-
// ===== Round 3: Wikipedia =====
|
|
247
|
-
|
|
248
|
-
describe('interact — Wikipedia', () => {
|
|
249
|
-
it('navigate article links', async () => {
|
|
250
|
-
const page = await connect();
|
|
251
|
-
try {
|
|
252
|
-
await page.goto('https://en.wikipedia.org/wiki/Web_browser');
|
|
253
|
-
let snap = await page.snapshot();
|
|
254
|
-
assert.ok(snap.toLowerCase().includes('web browser'), 'should load Wikipedia article');
|
|
255
|
-
|
|
256
|
-
// Find any article link to click
|
|
257
|
-
let linkRef = null;
|
|
258
|
-
const linkPattern = /link "[^"]*(?:software|internet|HTML|World Wide Web)[^"]*" \[ref=([^\]]+)\]/i;
|
|
259
|
-
const lm = snap.match(linkPattern);
|
|
260
|
-
if (lm) linkRef = lm[1];
|
|
261
|
-
assert.ok(linkRef, 'should find an article link');
|
|
262
|
-
|
|
263
|
-
const navPromise = page.waitForNavigation(10000);
|
|
264
|
-
await page.click(linkRef);
|
|
265
|
-
await navPromise;
|
|
266
|
-
|
|
267
|
-
snap = await page.snapshot();
|
|
268
|
-
assert.ok(snap.length > 100, 'new page should have content');
|
|
269
|
-
} finally {
|
|
270
|
-
await page.close();
|
|
271
|
-
}
|
|
272
|
-
});
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
// ===== Round 4: GitHub (SPA) =====
|
|
276
|
-
|
|
277
|
-
describe('interact — GitHub', () => {
|
|
278
|
-
it('navigate SPA repo links', async () => {
|
|
279
|
-
const page = await connect();
|
|
280
|
-
try {
|
|
281
|
-
await page.goto('https://github.com/anthropics');
|
|
282
|
-
let snap = await page.snapshot();
|
|
283
|
-
assert.ok(snap.toLowerCase().includes('anthropic'), 'should load GitHub org page');
|
|
284
|
-
|
|
285
|
-
// Find a repo link — match link with repo-like names
|
|
286
|
-
let repoRef = null;
|
|
287
|
-
const repoPattern = /link "(?:claude|anthropic|sdk)[^"]*" \[ref=([^\]]+)\]/i;
|
|
288
|
-
const rm = snap.match(repoPattern);
|
|
289
|
-
if (rm) repoRef = rm[1];
|
|
290
|
-
assert.ok(repoRef, 'should find a repo link');
|
|
291
|
-
|
|
292
|
-
await page.click(repoRef);
|
|
293
|
-
// GitHub SPAs may not fire loadEventFired; use settle time
|
|
294
|
-
await new Promise(r => setTimeout(r, 3000));
|
|
295
|
-
|
|
296
|
-
snap = await page.snapshot();
|
|
297
|
-
assert.ok(snap.length > 100, 'repo page should have content');
|
|
298
|
-
} finally {
|
|
299
|
-
await page.close();
|
|
300
|
-
}
|
|
301
|
-
});
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
// ===== Round 5: DuckDuckGo (headless-friendly search engine) =====
|
|
305
|
-
|
|
306
|
-
describe('interact — DuckDuckGo Search', () => {
|
|
307
|
-
it('search query and verify results page', async () => {
|
|
308
|
-
const page = await connect();
|
|
309
|
-
try {
|
|
310
|
-
await page.goto('https://duckduckgo.com');
|
|
311
|
-
// Use browse mode to get full tree including input elements
|
|
312
|
-
let snap = await page.snapshot({ mode: 'browse' });
|
|
313
|
-
assert.ok(snap.length > 50, 'DuckDuckGo homepage should load');
|
|
314
|
-
|
|
315
|
-
// Find the search box — could be combobox, textbox, or searchbox
|
|
316
|
-
let searchRef = null;
|
|
317
|
-
const searchPattern = /(?:combobox|textbox|searchbox)[^[]*?\[ref=([^\]]+)\]/g;
|
|
318
|
-
let m;
|
|
319
|
-
while ((m = searchPattern.exec(snap)) !== null) {
|
|
320
|
-
searchRef = m[1];
|
|
321
|
-
break;
|
|
322
|
-
}
|
|
323
|
-
// Fallback: look for any input-like element inside a LabelText or search region
|
|
324
|
-
if (!searchRef) {
|
|
325
|
-
const labelPattern = /LabelText[^[]*?\[ref=([^\]]+)\]/;
|
|
326
|
-
const lm = snap.match(labelPattern);
|
|
327
|
-
if (lm) searchRef = lm[1];
|
|
328
|
-
}
|
|
329
|
-
assert.ok(searchRef, `should find search box ref. Snapshot start: ${snap.substring(0, 500)}`);
|
|
330
|
-
|
|
331
|
-
await page.type(searchRef, 'node.js web framework');
|
|
332
|
-
const navPromise = page.waitForNavigation(15000);
|
|
333
|
-
await page.press('Enter');
|
|
334
|
-
await navPromise;
|
|
335
|
-
// Settle time for results to render
|
|
336
|
-
await new Promise(r => setTimeout(r, 2000));
|
|
337
|
-
|
|
338
|
-
snap = await page.snapshot();
|
|
339
|
-
assert.ok(snap.length > 200, 'results page should have substantial content');
|
|
340
|
-
// DuckDuckGo results should contain web-related terms
|
|
341
|
-
const lower = snap.toLowerCase();
|
|
342
|
-
assert.ok(
|
|
343
|
-
lower.includes('node') || lower.includes('web') || lower.includes('result'),
|
|
344
|
-
'results page should contain relevant content',
|
|
345
|
-
);
|
|
346
|
-
} finally {
|
|
347
|
-
await page.close();
|
|
348
|
-
}
|
|
349
|
-
});
|
|
350
|
-
});
|
|
351
|
-
|
|
352
|
-
// ===== Round 6: Hacker News (simple HTML, link navigation) =====
|
|
353
|
-
|
|
354
|
-
describe('interact — Hacker News', () => {
|
|
355
|
-
it('load homepage and navigate to a story', async () => {
|
|
356
|
-
const page = await connect();
|
|
357
|
-
try {
|
|
358
|
-
await page.goto('https://news.ycombinator.com');
|
|
359
|
-
let snap = await page.snapshot();
|
|
360
|
-
assert.ok(
|
|
361
|
-
snap.includes('Hacker News') || snap.includes('hacker news'),
|
|
362
|
-
'should find Hacker News in page content',
|
|
363
|
-
);
|
|
364
|
-
|
|
365
|
-
// Find a story link — HN stories are links, look for one with a ref
|
|
366
|
-
// Story links typically have descriptive names; grab any link that is not nav/header
|
|
367
|
-
let storyRef = null;
|
|
368
|
-
const linkPattern = /link "[^"]{10,}" \[ref=([^\]]+)\]/g;
|
|
369
|
-
let lm;
|
|
370
|
-
const skipWords = /^(Hacker News|login|submit|new|past|comments|ask|show|jobs|guidelines|faq|lists|more|favorite|flag|hide|next)/i;
|
|
371
|
-
while ((lm = linkPattern.exec(snap)) !== null) {
|
|
372
|
-
// Extract the link name for filtering
|
|
373
|
-
const nameMatch = lm[0].match(/link "([^"]+)"/);
|
|
374
|
-
if (nameMatch && !skipWords.test(nameMatch[1])) {
|
|
375
|
-
storyRef = lm[1];
|
|
376
|
-
break;
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
assert.ok(storyRef, 'should find a story link');
|
|
380
|
-
|
|
381
|
-
const navPromise = page.waitForNavigation(10000).catch(() => {});
|
|
382
|
-
await page.click(storyRef);
|
|
383
|
-
await navPromise;
|
|
384
|
-
// Some links are external sites; settle time for load
|
|
385
|
-
await new Promise(r => setTimeout(r, 2000));
|
|
386
|
-
|
|
387
|
-
snap = await page.snapshot();
|
|
388
|
-
assert.ok(snap.length > 50, 'navigated page should have content');
|
|
389
|
-
} finally {
|
|
390
|
-
await page.close();
|
|
391
|
-
}
|
|
392
|
-
});
|
|
393
|
-
});
|
|
394
|
-
|
|
395
|
-
// ===== Round 7: Reddit (old.reddit.com, better headless support) =====
|
|
396
|
-
|
|
397
|
-
describe('interact — Reddit (old)', () => {
|
|
398
|
-
it('load old.reddit.com and navigate to a post or subreddit', async () => {
|
|
399
|
-
const page = await connect();
|
|
400
|
-
try {
|
|
401
|
-
// old.reddit.com may redirect headless browsers; use waitForNavigation
|
|
402
|
-
const navPromise = page.waitForNavigation(10000).catch(() => {});
|
|
403
|
-
await page.goto('https://old.reddit.com');
|
|
404
|
-
await navPromise;
|
|
405
|
-
// Extra settle time — Reddit can be slow to render
|
|
406
|
-
await new Promise(r => setTimeout(r, 3000));
|
|
407
|
-
let snap = await page.snapshot({ mode: 'browse' });
|
|
408
|
-
|
|
409
|
-
// Reddit may serve a different page to headless; check what we got
|
|
410
|
-
if (snap.length < 200) {
|
|
411
|
-
// Try www.reddit.com as fallback
|
|
412
|
-
await page.goto('https://www.reddit.com');
|
|
413
|
-
await new Promise(r => setTimeout(r, 4000));
|
|
414
|
-
snap = await page.snapshot({ mode: 'browse' });
|
|
415
|
-
}
|
|
416
|
-
assert.ok(snap.length > 100, `Reddit should load with content. Got ${snap.length} chars`);
|
|
417
|
-
|
|
418
|
-
// Find a content link — look for links with descriptive text
|
|
419
|
-
let linkRef = null;
|
|
420
|
-
const linkPattern = /link "[^"]{10,}" \[ref=([^\]]+)\]/g;
|
|
421
|
-
let lm;
|
|
422
|
-
const skipReddit = /^(reddit|log\s*in|sign\s*up|register|preferences|wiki|rules|mod|gilded|promoted|comments|share|save|hide|report|permalink|give|embed|crosspost|get the app|cookie|privacy|user agreement|advertise)/i;
|
|
423
|
-
while ((lm = linkPattern.exec(snap)) !== null) {
|
|
424
|
-
const nameMatch = lm[0].match(/link "([^"]+)"/);
|
|
425
|
-
if (nameMatch && !skipReddit.test(nameMatch[1])) {
|
|
426
|
-
linkRef = lm[1];
|
|
427
|
-
break;
|
|
428
|
-
}
|
|
429
|
-
}
|
|
430
|
-
assert.ok(linkRef, 'should find a content link');
|
|
431
|
-
|
|
432
|
-
const clickNavPromise = page.waitForNavigation(10000).catch(() => {});
|
|
433
|
-
await page.click(linkRef);
|
|
434
|
-
await clickNavPromise;
|
|
435
|
-
await new Promise(r => setTimeout(r, 3000));
|
|
436
|
-
|
|
437
|
-
snap = await page.snapshot();
|
|
438
|
-
assert.ok(snap.length > 50, 'navigated page should have content');
|
|
439
|
-
} finally {
|
|
440
|
-
await page.close();
|
|
441
|
-
}
|
|
442
|
-
});
|
|
443
|
-
});
|
|
444
|
-
|
|
445
|
-
// ===== Round 8: Cookie injection from Firefox =====
|
|
446
|
-
|
|
447
|
-
describe('interact — Firefox cookie injection', () => {
|
|
448
|
-
it('extract Firefox cookies and inject into CDP session', async () => {
|
|
449
|
-
// Step 1: Extract Firefox cookies for github.com (best-effort)
|
|
450
|
-
let cookies;
|
|
451
|
-
try {
|
|
452
|
-
cookies = extractCookies({ browser: 'firefox', domain: 'github.com' });
|
|
453
|
-
} catch (err) {
|
|
454
|
-
// Firefox DB may not exist or may be locked — skip gracefully
|
|
455
|
-
console.log(`Skipping cookie injection test: ${err.message}`);
|
|
456
|
-
return;
|
|
457
|
-
}
|
|
458
|
-
assert.ok(Array.isArray(cookies), 'extractCookies should return an array');
|
|
459
|
-
// If no cookies for github.com, try a broader extraction
|
|
460
|
-
if (cookies.length === 0) {
|
|
461
|
-
try {
|
|
462
|
-
cookies = extractCookies({ browser: 'firefox', domain: 'google.com' });
|
|
463
|
-
} catch {
|
|
464
|
-
// Still no luck — that's fine, verify the basic path works
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
// Step 2: Connect without cookies (cookies: false)
|
|
469
|
-
const page = await connect({ cookies: false });
|
|
470
|
-
try {
|
|
471
|
-
// Step 3: Inject cookies into the CDP session (should not throw even if empty)
|
|
472
|
-
if (cookies && cookies.length > 0) {
|
|
473
|
-
await injectCookies(page.cdp, cookies);
|
|
474
|
-
// Verify cookies were injected by checking CDP's cookie jar
|
|
475
|
-
const { cookies: cdpCookies } = await page.cdp.send('Network.getAllCookies');
|
|
476
|
-
assert.ok(cdpCookies.length > 0, 'CDP session should have cookies after injection');
|
|
477
|
-
|
|
478
|
-
// Step 4: Navigate to the domain and verify page loads
|
|
479
|
-
await page.goto('https://github.com');
|
|
480
|
-
await new Promise(r => setTimeout(r, 2000));
|
|
481
|
-
const snap = await page.snapshot();
|
|
482
|
-
assert.ok(snap.length > 100, 'GitHub should load after cookie injection');
|
|
483
|
-
// Best-effort: if user is logged in, there might be username/avatar in snapshot
|
|
484
|
-
// We don't assert auth state since we can't guarantee it
|
|
485
|
-
} else {
|
|
486
|
-
// No cookies extracted — just verify the functions don't throw
|
|
487
|
-
await injectCookies(page.cdp, []);
|
|
488
|
-
console.log('No Firefox cookies found for test domains; injection path verified with empty array');
|
|
489
|
-
}
|
|
490
|
-
} finally {
|
|
491
|
-
await page.close();
|
|
492
|
-
}
|
|
493
|
-
});
|
|
494
|
-
|
|
495
|
-
it('extractCookies with firefox browser returns array', () => {
|
|
496
|
-
try {
|
|
497
|
-
const cookies = extractCookies({ browser: 'firefox' });
|
|
498
|
-
assert.ok(Array.isArray(cookies), 'should return an array');
|
|
499
|
-
if (cookies.length > 0) {
|
|
500
|
-
// Verify cookie shape
|
|
501
|
-
const c = cookies[0];
|
|
502
|
-
assert.ok(typeof c.name === 'string', 'cookie should have name');
|
|
503
|
-
assert.ok(typeof c.value === 'string', 'cookie should have value');
|
|
504
|
-
assert.ok(typeof c.domain === 'string', 'cookie should have domain');
|
|
505
|
-
}
|
|
506
|
-
} catch (err) {
|
|
507
|
-
// Firefox not available or DB locked — acceptable
|
|
508
|
-
assert.ok(
|
|
509
|
-
err.message.includes('not found') || err.message.includes('locked') || err.message.includes('SQLITE'),
|
|
510
|
-
`unexpected error: ${err.message}`,
|
|
511
|
-
);
|
|
512
|
-
}
|
|
513
|
-
});
|
|
514
|
-
});
|
package/test/unit/auth.test.js
DELETED
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Unit tests for cookie extraction.
|
|
3
|
-
* Tests what's available on this system (Firefox on Fedora/KDE).
|
|
4
|
-
*
|
|
5
|
-
* Run: node --test test/unit/auth.test.js
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { describe, it } from 'node:test';
|
|
9
|
-
import assert from 'node:assert/strict';
|
|
10
|
-
import { extractCookies } from '../../src/auth.js';
|
|
11
|
-
|
|
12
|
-
describe('extractCookies()', () => {
|
|
13
|
-
it('auto-detects a browser and returns cookies', () => {
|
|
14
|
-
const cookies = extractCookies();
|
|
15
|
-
assert.ok(Array.isArray(cookies), 'should return an array');
|
|
16
|
-
assert.ok(cookies.length > 0, 'should find at least some cookies');
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
it('returns cookies with correct shape', () => {
|
|
20
|
-
const cookies = extractCookies();
|
|
21
|
-
const cookie = cookies[0];
|
|
22
|
-
assert.ok('name' in cookie, 'should have name');
|
|
23
|
-
assert.ok('value' in cookie, 'should have value');
|
|
24
|
-
assert.ok('domain' in cookie, 'should have domain');
|
|
25
|
-
assert.ok('path' in cookie, 'should have path');
|
|
26
|
-
assert.ok('secure' in cookie, 'should have secure');
|
|
27
|
-
assert.ok('httpOnly' in cookie, 'should have httpOnly');
|
|
28
|
-
assert.ok('sameSite' in cookie, 'should have sameSite');
|
|
29
|
-
assert.ok('expires' in cookie, 'should have expires');
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
it('filters by domain', () => {
|
|
33
|
-
const cookies = extractCookies({ domain: 'google.com' });
|
|
34
|
-
for (const cookie of cookies) {
|
|
35
|
-
assert.ok(cookie.domain.includes('google'), `${cookie.domain} should match google`);
|
|
36
|
-
}
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it('extracts from firefox explicitly', () => {
|
|
40
|
-
const cookies = extractCookies({ browser: 'firefox' });
|
|
41
|
-
assert.ok(cookies.length > 0, 'should find Firefox cookies');
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it('throws for non-existent browser', () => {
|
|
45
|
-
assert.throws(
|
|
46
|
-
() => extractCookies({ browser: 'chrome' }),
|
|
47
|
-
/not found/i,
|
|
48
|
-
'should throw for missing Chrome'
|
|
49
|
-
);
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
it('cookies have non-empty values', () => {
|
|
53
|
-
const cookies = extractCookies();
|
|
54
|
-
for (const cookie of cookies) {
|
|
55
|
-
assert.ok(cookie.value.length > 0, `cookie ${cookie.name} should have a value`);
|
|
56
|
-
}
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
it('sameSite is a valid value', () => {
|
|
60
|
-
const valid = new Set(['None', 'Lax', 'Strict']);
|
|
61
|
-
const cookies = extractCookies();
|
|
62
|
-
for (const cookie of cookies) {
|
|
63
|
-
assert.ok(valid.has(cookie.sameSite), `${cookie.sameSite} should be valid sameSite`);
|
|
64
|
-
}
|
|
65
|
-
});
|
|
66
|
-
});
|