+171
-71
@@ -1,10 +1,36 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { BrowserClient, resolveNodeBinary } from '../browser-use/client.js';
|
||||
import { createBrowserTools } from '../skills/browser-tools.js';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const web = require('../vendor/agent-harness/agent/web-search.js');
|
||||
const reader = require('../vendor/agent-harness/agent/web-reader.js');
|
||||
const response = (url, text, status = 200, headers = {}) => ({ url: String(url), status, text: async () => text, headers: { get: key => headers[key] || null } });
|
||||
|
||||
test('browser-use maps Node builtins so Bare can load the sidecar client', () => {
|
||||
const pkg = JSON.parse(readFileSync(new URL('../browser-use/package.json', import.meta.url), 'utf8'));
|
||||
assert.equal(pkg.imports['node:child_process'].bare, 'bare-subprocess');
|
||||
assert.equal(pkg.imports['node:os'].bare, 'bare-os');
|
||||
assert.equal(pkg.imports['node:path'].bare, 'bare-path');
|
||||
});
|
||||
|
||||
function mockBackend(handler) {
|
||||
return { call: (action, payload, timeoutMs) => handler(action, payload, timeoutMs) };
|
||||
}
|
||||
|
||||
function helper() {
|
||||
const child = new EventEmitter();
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
child.stdin = new EventEmitter();
|
||||
child.stdin.writable = true;
|
||||
child.stdin.write = (chunk) => { child.written = String(chunk); return true; };
|
||||
child.kill = () => { child.killed = true; };
|
||||
return child;
|
||||
}
|
||||
|
||||
test('reader extracts structured content, relative links, entities, pagination and find', () => {
|
||||
const html = `<html><head><title>A & B</title><meta name='description' content='A guide'></head><body><nav>Ignore navigation</nav><main><h1>Guide 🚀</h1><p>${'Useful text. '.repeat(40)}</p><a href='../next?utm_source=x&q=one'>Next</a><script>malicious()</script></main></body></html>`;
|
||||
@@ -16,86 +42,160 @@ test('reader extracts structured content, relative links, entities, pagination a
|
||||
assert.equal(page.next_offset, 200);
|
||||
assert.equal(page.matches.length, 20);
|
||||
assert.doesNotMatch(page.text, /navigation|malicious/);
|
||||
assert.equal(reader.extractPage(html, 'https://example.com', { offset: 200 }).text, reader.extractPage(html, 'https://example.com').text.slice(200));
|
||||
});
|
||||
|
||||
test('DDG parses reordered, single-quoted attributes and snippets', () => {
|
||||
const hits = web.parseDdgHtmlHits(`<a href='/l/?uddg=https%3A%2F%2Fexample.com%2Fa%253Fb' class='extra result__a'>Title — test</a><div class='result__snippet'>The snippet</div>`);
|
||||
assert.equal(hits[0].url, 'https://example.com/a%3Fb');
|
||||
assert.equal(hits[0].title, 'Title — test');
|
||||
assert.equal(hits[0].snippet, 'The snippet');
|
||||
assert.equal(web.parseDdgLiteHits(`<a href='https://example.com' class='result-link'>Lite</a>`)[0].title, 'Lite');
|
||||
});
|
||||
|
||||
test('auto merges scraped engines and removes tracking duplicates without calling APIs', async () => {
|
||||
const orig = globalThis.fetch; const calls = [];
|
||||
globalThis.fetch = async url => {
|
||||
calls.push(String(url));
|
||||
if (String(url).includes('duckduckgo')) return response(url, '<a class="result__a" href="https://example.com/a?utm_source=ddg">Example</a>');
|
||||
if (String(url).includes('bing')) return response(url, '<rss><item><title>Example</title><link>https://example.com/a</link></item><item><title>Other</title><link>https://other.example/b</link></item></rss>');
|
||||
return response(url, '');
|
||||
};
|
||||
test('web search and fetch go through the browser helper instead of HTML scrapers', async () => {
|
||||
const calls = [];
|
||||
web.setBrowserBackend(mockBackend(async (action, payload) => {
|
||||
calls.push([action, payload.engine || payload.url]);
|
||||
if (action === 'search') return [{ url: 'https://example.com/hit', title: 'Example', snippet: 'Hello', source: payload.engine }];
|
||||
return { url: payload.url, status: 200, html: '<html><body><p>Readable article</p></body></html>', text: 'Readable article', via: 'browser' };
|
||||
}));
|
||||
try {
|
||||
const hits = await web.runWebSearch('example', { limit: 2, timeoutMs: 500 });
|
||||
assert.equal(hits.length, 2);
|
||||
assert.deepEqual(hits[0].sources, ['duckduckgo', 'bing_rss']);
|
||||
assert.equal(hits[0].url, 'https://example.com/a');
|
||||
assert.ok(calls.every(url => !/jina|api\.|\/api\//.test(url)));
|
||||
assert.match((await web.runWebSearch('test', { engine: 'jina' })).error, /unknown engine/);
|
||||
} finally { globalThis.fetch = orig; }
|
||||
const hits = await web.runWebSearch('example', { timeoutMs: 200 });
|
||||
assert.equal(hits[0].url, 'https://example.com/hit');
|
||||
assert.equal(calls[0][0], 'search');
|
||||
const page = await web.fetchPage('https://example.com/article', 200);
|
||||
assert.equal(page.via, 'browser');
|
||||
assert.match(page.text, /Readable article/);
|
||||
const google = await web.googleSearchWithFallback('example', 200);
|
||||
assert.equal(google[0].source, 'google');
|
||||
} finally {
|
||||
web.setBrowserBackend(null);
|
||||
}
|
||||
});
|
||||
|
||||
test('direct fetch checks redirects, rejects binary pages, and reports challenges', async () => {
|
||||
const orig = globalThis.fetch; let calls = 0;
|
||||
test('unknown engines and private URLs fail before Chromium starts', async () => {
|
||||
web.setBrowserBackend(mockBackend(async () => assert.fail('backend should not run')));
|
||||
try {
|
||||
globalThis.fetch = async url => { calls++; return response(url, '', 302, { location: 'http://127.0.0.1/secret' }); };
|
||||
assert.match((await web.fetchPage('https://example.com')).error, /blocked/);
|
||||
assert.equal(calls, 1);
|
||||
globalThis.fetch = async url => response(url, 'binary', 200, { 'content-type': 'application/pdf' });
|
||||
assert.match((await web.fetchPage('https://example.com')).error, /unsupported content type/);
|
||||
globalThis.fetch = async url => response(url, '<p>Verify you are human</p>');
|
||||
assert.match((await web.fetchPage('https://challenge.example.com')).warning, /challenge/);
|
||||
globalThis.fetch = async url => response(url, 'x'.repeat(2 * 1024 * 1024 + 1));
|
||||
assert.match((await web.fetchPage('https://example.com')).error, /limit/);
|
||||
} finally { globalThis.fetch = orig; }
|
||||
const unknown = await web.runWebSearch('example', { engine: 'jina' });
|
||||
assert.match(String(unknown.error), /unknown engine/);
|
||||
const blocked = await web.fetchPage('http://127.0.0.1/secret', 200);
|
||||
assert.match(String(blocked.error), /blocked/);
|
||||
} finally {
|
||||
web.setBrowserBackend(null);
|
||||
}
|
||||
});
|
||||
|
||||
test('stalled streaming bodies are cancelled at the deadline', async () => {
|
||||
let cancelled = false;
|
||||
const res = new Response(new ReadableStream({ cancel() { cancelled = true; } }));
|
||||
await assert.rejects(web.readBodyWithTimeout(res, 30), /timed out/);
|
||||
assert.equal(cancelled, true);
|
||||
});
|
||||
|
||||
|
||||
test('challenge responses fall back and cool down related search hosts', async () => {
|
||||
const orig = globalThis.fetch; const calls = [];
|
||||
globalThis.fetch = async url => {
|
||||
calls.push(String(url));
|
||||
if (String(url).includes('duckduckgo')) return response(url, '<form id="challenge-form">Verify you are human</form>');
|
||||
if (String(url).includes('bing')) return response(url, '<rss><item><title>Available</title><link>https://available.example/page</link></item></rss>');
|
||||
return response(url, '');
|
||||
};
|
||||
test('search and fetch stop at the overall budget instead of hanging', async () => {
|
||||
web.setBrowserBackend(mockBackend(() => new Promise(() => {})));
|
||||
try {
|
||||
const hits = await web.runWebSearch('fallback', { limit: 1 });
|
||||
assert.equal(hits[0].source, 'bing_rss');
|
||||
const blocked = await web.runWebSearch('fallback', { engine: 'ddg_lite' });
|
||||
assert.equal(blocked.code, 'bot_challenge');
|
||||
assert.ok(blocked.retry_after_ms > 0);
|
||||
assert.equal(calls.filter(url => url.includes('duckduckgo')).length, 1);
|
||||
} finally { globalThis.fetch = orig; }
|
||||
const started = Date.now();
|
||||
const search = await web.runWebSearch('example', { timeoutMs: 80 });
|
||||
assert.match(String(search.error), /timed out/i);
|
||||
assert.ok(Date.now() - started < 500);
|
||||
const page = await web.webFetch('https://example.com/ip', 40);
|
||||
assert.match(String(page.error), /timed out/i);
|
||||
assert.equal(page.url, 'https://example.com/ip');
|
||||
} finally {
|
||||
web.setBrowserBackend(null);
|
||||
}
|
||||
});
|
||||
|
||||
test('rate limits preserve retry guidance and never expose challenge content', async () => {
|
||||
const orig = globalThis.fetch; let calls = 0;
|
||||
globalThis.fetch = async url => { calls++; return response(url, 'blocked content', 429, { 'retry-after': '120' }); };
|
||||
test('a remaining challenge tells the user to finish it in the Jarvis window', async () => {
|
||||
web.setBrowserBackend(mockBackend(async () => ({
|
||||
url: 'https://challenge.example/page',
|
||||
status: 200,
|
||||
html: '<html><title>Just a moment</title><body>Checking your browser</body></html>',
|
||||
text: 'Checking your browser',
|
||||
challenge: true,
|
||||
next_action: 'Complete the prompt in the Jarvis browser window, then call the tool again.',
|
||||
})));
|
||||
try {
|
||||
const result = await web.fetchPage('https://limited.example/page');
|
||||
assert.equal(result.code, 'rate_limited');
|
||||
assert.equal(result.retry_after_ms, 120000);
|
||||
assert.equal(result.text, undefined);
|
||||
assert.match(result.next_action, /browser/);
|
||||
await web.fetchPage('https://limited.example/other');
|
||||
assert.equal(calls, 1);
|
||||
} finally { globalThis.fetch = orig; }
|
||||
const page = await web.fetchPage('https://challenge.example/page', 200);
|
||||
assert.equal(page.challenge, true);
|
||||
assert.match(page.next_action, /Jarvis browser/);
|
||||
} finally {
|
||||
web.setBrowserBackend(null);
|
||||
}
|
||||
});
|
||||
|
||||
test('auto search falls back to Google when DuckDuckGo has no hits', async () => {
|
||||
const engines = [];
|
||||
web.setBrowserBackend(mockBackend(async (action, payload) => {
|
||||
engines.push(payload.engine);
|
||||
if (payload.engine === 'duckduckgo') return [];
|
||||
return [{ url: 'https://example.com/g', title: 'From Google', source: 'google' }];
|
||||
}));
|
||||
try {
|
||||
const hits = await web.runWebSearch('example', { timeoutMs: 200 });
|
||||
assert.deepEqual(engines, ['duckduckgo', 'google']);
|
||||
assert.equal(hits[0].source, 'google');
|
||||
} finally {
|
||||
web.setBrowserBackend(null);
|
||||
}
|
||||
});
|
||||
|
||||
test('code_search queries GitHub, npm, and MDN through the helper', async () => {
|
||||
const engines = [];
|
||||
web.setBrowserBackend(mockBackend(async (_action, payload) => {
|
||||
engines.push(payload.engine);
|
||||
return [{ url: 'https://example.com/' + payload.engine, title: payload.engine, source: payload.engine }];
|
||||
}));
|
||||
try {
|
||||
const out = await web.codeSearch('playwright', 200);
|
||||
assert.deepEqual(engines, ['github', 'npm', 'mdn']);
|
||||
assert.equal(out.github[0].source, 'github');
|
||||
assert.equal(out.npm[0].source, 'npm');
|
||||
assert.equal(out.mdn[0].source, 'mdn');
|
||||
} finally {
|
||||
web.setBrowserBackend(null);
|
||||
}
|
||||
});
|
||||
|
||||
test('browser client maps spawn ENOENT to a Node install hint', async () => {
|
||||
const child = helper();
|
||||
const client = new BrowserClient({
|
||||
lookupBin: false,
|
||||
spawnImpl: () => {
|
||||
setImmediate(() => child.emit('error', Object.assign(new Error('no such file or directory'), { code: 'ENOENT' })));
|
||||
return child;
|
||||
},
|
||||
timeoutMs: 80,
|
||||
});
|
||||
const result = await client.call('navigate', { url: 'https://example.com' });
|
||||
assert.match(String(result.error), /Node\.js is not available to jarvisd/);
|
||||
assert.doesNotMatch(String(result.error), /^no such file or directory$/i);
|
||||
});
|
||||
|
||||
test('browser client explains a missing helper script', async () => {
|
||||
const node = resolveNodeBinary() || process.execPath;
|
||||
const client = new BrowserClient({ node, command: '/no/such/jarvis-browser-helper.js' });
|
||||
const result = await client.call('snapshot', {});
|
||||
assert.match(String(result.error), /browser helper is missing/);
|
||||
});
|
||||
|
||||
test('browser helper speaks JSON lines and the client times out hung actions', async () => {
|
||||
const child = helper();
|
||||
const client = new BrowserClient({ spawnImpl: () => child, timeoutMs: 80 });
|
||||
const ready = client.ensure();
|
||||
child.stdout.emit('data', '{"type":"ready","headless":true}\n');
|
||||
await ready;
|
||||
const pending = client.call('fetch', { url: 'https://example.com' }, 50);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.match(child.written, /"action":"fetch"/);
|
||||
child.stdout.emit('data', '{"id":1,"ok":true,"result":{"url":"https://example.com","title":"Example","text":"ok"}}\n');
|
||||
assert.equal((await pending).title, 'Example');
|
||||
const hungStarted = Date.now();
|
||||
const hung = await client.call('snapshot', {}, 40);
|
||||
assert.match(String(hung.error), /timed out/i);
|
||||
assert.ok(Date.now() - hungStarted >= 20);
|
||||
client.close();
|
||||
assert.equal(child.killed, true);
|
||||
});
|
||||
|
||||
test('the browser gateway forwards snapshot click and type', async () => {
|
||||
const calls = [];
|
||||
const tools = createBrowserTools({
|
||||
browser: { call: async (action, payload) => { calls.push([action, payload.ref || payload.url]); return { ok: true, action, refs: [{ ref: '1', name: 'Next' }] }; } },
|
||||
});
|
||||
assert.equal(tools[0].name, 'browser');
|
||||
assert.equal(tools[0].permission, 'read');
|
||||
assert.deepEqual(tools[0].parameters.properties.action.enum, ['navigate', 'snapshot', 'click', 'type', 'press', 'scroll', 'wait']);
|
||||
assert.match(tools[0].description, /same Playwright session/);
|
||||
assert.match(tools[0].description, /refs change/);
|
||||
await tools[0].execute({ action: 'snapshot' });
|
||||
await tools[0].execute({ action: 'click', ref: '1' });
|
||||
await tools[0].execute({ action: 'navigate', url: 'https://example.com' });
|
||||
assert.deepEqual(calls, [['snapshot', undefined], ['click', '1'], ['navigate', 'https://example.com']]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user