202 lines
8.9 KiB
JavaScript
202 lines
8.9 KiB
JavaScript
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');
|
|
|
|
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>`;
|
|
const page = reader.extractPage(html, 'https://example.com/docs/start', { max_chars: 200, find: 'Useful' });
|
|
assert.equal(page.title, 'A & B');
|
|
assert.equal(page.headings[0].text, 'Guide 🚀');
|
|
assert.equal(page.links[0].url, 'https://example.com/next?q=one');
|
|
assert.equal(page.metadata.description, 'A guide');
|
|
assert.equal(page.next_offset, 200);
|
|
assert.equal(page.matches.length, 20);
|
|
assert.doesNotMatch(page.text, /navigation|malicious/);
|
|
});
|
|
|
|
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', { 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('unknown engines and private URLs fail before Chromium starts', async () => {
|
|
web.setBrowserBackend(mockBackend(async () => assert.fail('backend should not run')));
|
|
try {
|
|
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('search and fetch stop at the overall budget instead of hanging', async () => {
|
|
web.setBrowserBackend(mockBackend(() => new Promise(() => {})));
|
|
try {
|
|
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('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 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, /duckduckgo\.com\/\?q=/);
|
|
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']]);
|
|
});
|