mcpbrowser 0.2.18 → 0.2.21
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/.github/workflows/test.yml +33 -0
- package/README.md +1 -1
- package/extension/package.json +1 -1
- package/extension/src/extension.js +1 -1
- package/package.json +4 -2
- package/server.json +1 -1
- package/src/mcp-browser.js +185 -68
- package/test-mcp.js +63 -0
- package/tests/domain-tab-pooling.test.js +329 -0
- package/tests/integration.test.js +158 -0
- package/tests/mcp-server.test.js +154 -0
- package/tests/prepare-html.test.js +307 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UNIT TESTS - Automated tests using mock objects (NO browser required)
|
|
3
|
+
* These tests validate domain pooling logic without opening Chrome
|
|
4
|
+
* Run with: node tests/domain-tab-pooling.test.js
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// Mock domain pages map and browser
|
|
8
|
+
class MockPage {
|
|
9
|
+
constructor(url) {
|
|
10
|
+
this._url = url;
|
|
11
|
+
this._closed = false;
|
|
12
|
+
this._content = '';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
url() { return this._url; }
|
|
16
|
+
isClosed() { return this._closed; }
|
|
17
|
+
close() { this._closed = true; }
|
|
18
|
+
async bringToFront() {}
|
|
19
|
+
async goto(url) {
|
|
20
|
+
this._url = url;
|
|
21
|
+
// Simulate eng.ms page with multiple same-domain links
|
|
22
|
+
if (url.includes('eng.ms/docs/products/geneva')) {
|
|
23
|
+
this._content = `
|
|
24
|
+
<html>
|
|
25
|
+
<body>
|
|
26
|
+
<h1>Geneva Documentation</h1>
|
|
27
|
+
<a href="https://eng.ms/docs/products/geneva/getting-started">Getting Started</a>
|
|
28
|
+
<a href="https://eng.ms/docs/products/geneva/configuration">Configuration</a>
|
|
29
|
+
<a href="https://eng.ms/docs/products/geneva/monitoring">Monitoring</a>
|
|
30
|
+
<a href="https://eng.ms/docs/products/geneva/alerts">Alerts</a>
|
|
31
|
+
<a href="https://eng.ms/docs/products/geneva/best-practices">Best Practices</a>
|
|
32
|
+
<a href="https://external.com/link">External Link</a>
|
|
33
|
+
</body>
|
|
34
|
+
</html>
|
|
35
|
+
`;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
async evaluate(fn) {
|
|
39
|
+
if (this._content) {
|
|
40
|
+
return fn.toString().includes('outerHTML') ? this._content : fn();
|
|
41
|
+
}
|
|
42
|
+
return fn();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
class MockBrowser {
|
|
47
|
+
constructor() {
|
|
48
|
+
this._pages = [];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async newPage() {
|
|
52
|
+
const page = new MockPage('about:blank');
|
|
53
|
+
this._pages.push(page);
|
|
54
|
+
return page;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async pages() {
|
|
58
|
+
return this._pages;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Test framework
|
|
63
|
+
let testsPassed = 0;
|
|
64
|
+
let testsFailed = 0;
|
|
65
|
+
|
|
66
|
+
function assert(condition, message) {
|
|
67
|
+
if (!condition) {
|
|
68
|
+
console.error(`❌ FAILED: ${message}`);
|
|
69
|
+
testsFailed++;
|
|
70
|
+
throw new Error(message);
|
|
71
|
+
} else {
|
|
72
|
+
console.log(`✅ PASSED: ${message}`);
|
|
73
|
+
testsPassed++;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function test(name, fn) {
|
|
78
|
+
console.log(`\n🧪 Test: ${name}`);
|
|
79
|
+
try {
|
|
80
|
+
await fn();
|
|
81
|
+
} catch (error) {
|
|
82
|
+
console.error(` Error: ${error.message}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Tests
|
|
87
|
+
async function runTests() {
|
|
88
|
+
console.log('🚀 Starting Domain Tab Pooling Tests\n');
|
|
89
|
+
|
|
90
|
+
await test('Should create new tab for first domain', async () => {
|
|
91
|
+
const domainPages = new Map();
|
|
92
|
+
const browser = new MockBrowser();
|
|
93
|
+
const url = 'https://github.com/user/repo';
|
|
94
|
+
const hostname = new URL(url).hostname;
|
|
95
|
+
|
|
96
|
+
// No existing page for this domain
|
|
97
|
+
assert(!domainPages.has(hostname), 'Domain should not exist in map initially');
|
|
98
|
+
|
|
99
|
+
// Create new page
|
|
100
|
+
const page = await browser.newPage();
|
|
101
|
+
domainPages.set(hostname, page);
|
|
102
|
+
|
|
103
|
+
assert(domainPages.has(hostname), 'Domain should be added to map');
|
|
104
|
+
assert(domainPages.get(hostname) === page, 'Correct page should be stored');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
await test('Should reuse tab for same domain', async () => {
|
|
108
|
+
const domainPages = new Map();
|
|
109
|
+
const browser = new MockBrowser();
|
|
110
|
+
const hostname = 'github.com';
|
|
111
|
+
|
|
112
|
+
// Create first page for domain
|
|
113
|
+
const page1 = await browser.newPage();
|
|
114
|
+
await page1.goto('https://github.com/repo1');
|
|
115
|
+
domainPages.set(hostname, page1);
|
|
116
|
+
|
|
117
|
+
// Try to fetch another URL from same domain
|
|
118
|
+
const existingPage = domainPages.get(hostname);
|
|
119
|
+
assert(existingPage === page1, 'Should return same page for same domain');
|
|
120
|
+
assert(!existingPage.isClosed(), 'Page should still be open');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
await test('Should create new tab for different domain', async () => {
|
|
124
|
+
const domainPages = new Map();
|
|
125
|
+
const browser = new MockBrowser();
|
|
126
|
+
|
|
127
|
+
// First domain
|
|
128
|
+
const page1 = await browser.newPage();
|
|
129
|
+
await page1.goto('https://github.com/repo');
|
|
130
|
+
domainPages.set('github.com', page1);
|
|
131
|
+
|
|
132
|
+
// Second domain - should create new tab
|
|
133
|
+
const hostname2 = 'microsoft.com';
|
|
134
|
+
assert(!domainPages.has(hostname2), 'Second domain should not exist yet');
|
|
135
|
+
|
|
136
|
+
const page2 = await browser.newPage();
|
|
137
|
+
await page2.goto('https://microsoft.com/docs');
|
|
138
|
+
domainPages.set(hostname2, page2);
|
|
139
|
+
|
|
140
|
+
assert(domainPages.has('github.com'), 'First domain should still exist');
|
|
141
|
+
assert(domainPages.has('microsoft.com'), 'Second domain should now exist');
|
|
142
|
+
assert(page1 !== page2, 'Should be different page objects');
|
|
143
|
+
assert(!page1.isClosed(), 'First page should still be open');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
await test('Should reuse tab when returning to previous domain', async () => {
|
|
147
|
+
const domainPages = new Map();
|
|
148
|
+
const browser = new MockBrowser();
|
|
149
|
+
|
|
150
|
+
// Domain 1
|
|
151
|
+
const page1 = await browser.newPage();
|
|
152
|
+
domainPages.set('github.com', page1);
|
|
153
|
+
|
|
154
|
+
// Domain 2
|
|
155
|
+
const page2 = await browser.newPage();
|
|
156
|
+
domainPages.set('microsoft.com', page2);
|
|
157
|
+
|
|
158
|
+
// Back to domain 1
|
|
159
|
+
const reusedPage = domainPages.get('github.com');
|
|
160
|
+
assert(reusedPage === page1, 'Should reuse original page for domain 1');
|
|
161
|
+
assert(!reusedPage.isClosed(), 'Reused page should still be open');
|
|
162
|
+
assert(domainPages.size === 2, 'Should have 2 domains in map');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
await test('Should handle closed tabs gracefully', async () => {
|
|
166
|
+
const domainPages = new Map();
|
|
167
|
+
const browser = new MockBrowser();
|
|
168
|
+
const hostname = 'github.com';
|
|
169
|
+
|
|
170
|
+
// Create and store page
|
|
171
|
+
const page = await browser.newPage();
|
|
172
|
+
domainPages.set(hostname, page);
|
|
173
|
+
|
|
174
|
+
// Simulate user closing the tab
|
|
175
|
+
page.close();
|
|
176
|
+
|
|
177
|
+
// Check if page is closed
|
|
178
|
+
const existingPage = domainPages.get(hostname);
|
|
179
|
+
if (existingPage && existingPage.isClosed()) {
|
|
180
|
+
domainPages.delete(hostname);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
assert(!domainPages.has(hostname), 'Closed page should be removed from map');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
await test('Should extract hostname correctly from URLs', async () => {
|
|
187
|
+
const testCases = [
|
|
188
|
+
{ url: 'https://github.com/user/repo', expected: 'github.com' },
|
|
189
|
+
{ url: 'https://microsoft.com/docs/page', expected: 'microsoft.com' },
|
|
190
|
+
{ url: 'https://subdomain.example.com/path', expected: 'subdomain.example.com' },
|
|
191
|
+
{ url: 'http://localhost:3000/test', expected: 'localhost' },
|
|
192
|
+
];
|
|
193
|
+
|
|
194
|
+
for (const { url, expected } of testCases) {
|
|
195
|
+
const hostname = new URL(url).hostname;
|
|
196
|
+
assert(hostname === expected, `Hostname for ${url} should be ${expected}, got ${hostname}`);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
await test('Should handle invalid URLs', async () => {
|
|
201
|
+
let errorThrown = false;
|
|
202
|
+
try {
|
|
203
|
+
new URL('not-a-valid-url');
|
|
204
|
+
} catch (error) {
|
|
205
|
+
errorThrown = true;
|
|
206
|
+
}
|
|
207
|
+
assert(errorThrown, 'Invalid URL should throw error');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
await test('Should clear all pages on browser disconnect', async () => {
|
|
211
|
+
const domainPages = new Map();
|
|
212
|
+
const browser = new MockBrowser();
|
|
213
|
+
|
|
214
|
+
// Add multiple domains
|
|
215
|
+
const page1 = await browser.newPage();
|
|
216
|
+
domainPages.set('github.com', page1);
|
|
217
|
+
|
|
218
|
+
const page2 = await browser.newPage();
|
|
219
|
+
domainPages.set('microsoft.com', page2);
|
|
220
|
+
|
|
221
|
+
const page3 = await browser.newPage();
|
|
222
|
+
domainPages.set('google.com', page3);
|
|
223
|
+
|
|
224
|
+
assert(domainPages.size === 3, 'Should have 3 domains before disconnect');
|
|
225
|
+
|
|
226
|
+
// Simulate browser disconnect
|
|
227
|
+
domainPages.clear();
|
|
228
|
+
|
|
229
|
+
assert(domainPages.size === 0, 'All domains should be cleared after disconnect');
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
await test('Should handle multiple requests to same domain', async () => {
|
|
233
|
+
const domainPages = new Map();
|
|
234
|
+
const browser = new MockBrowser();
|
|
235
|
+
const hostname = 'github.com';
|
|
236
|
+
|
|
237
|
+
// First request
|
|
238
|
+
const page = await browser.newPage();
|
|
239
|
+
await page.goto('https://github.com/repo1');
|
|
240
|
+
domainPages.set(hostname, page);
|
|
241
|
+
|
|
242
|
+
// Multiple subsequent requests to same domain
|
|
243
|
+
for (let i = 2; i <= 5; i++) {
|
|
244
|
+
const existingPage = domainPages.get(hostname);
|
|
245
|
+
assert(existingPage === page, `Request ${i} should reuse same page`);
|
|
246
|
+
await existingPage.goto(`https://github.com/repo${i}`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
assert(domainPages.size === 1, 'Should still have only 1 domain in map');
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
await test('Should open internal eng.ms page', async () => {
|
|
253
|
+
const domainPages = new Map();
|
|
254
|
+
const browser = new MockBrowser();
|
|
255
|
+
const url = 'https://eng.ms/docs/products/geneva';
|
|
256
|
+
const hostname = new URL(url).hostname;
|
|
257
|
+
|
|
258
|
+
// First request to eng.ms domain
|
|
259
|
+
assert(!domainPages.has(hostname), 'eng.ms domain should not exist initially');
|
|
260
|
+
|
|
261
|
+
const page = await browser.newPage();
|
|
262
|
+
await page.goto(url);
|
|
263
|
+
domainPages.set(hostname, page);
|
|
264
|
+
|
|
265
|
+
assert(domainPages.has(hostname), 'eng.ms domain should be added to map');
|
|
266
|
+
assert(page.url() === url, 'Page URL should match requested URL');
|
|
267
|
+
assert(!page.isClosed(), 'Page should remain open');
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
await test('Should extract and load 5 URLs from same domain', async () => {
|
|
271
|
+
const domainPages = new Map();
|
|
272
|
+
const browser = new MockBrowser();
|
|
273
|
+
const initialUrl = 'https://eng.ms/docs/products/geneva';
|
|
274
|
+
const hostname = new URL(initialUrl).hostname;
|
|
275
|
+
|
|
276
|
+
// First: Load the initial page
|
|
277
|
+
const page = await browser.newPage();
|
|
278
|
+
await page.goto(initialUrl);
|
|
279
|
+
domainPages.set(hostname, page);
|
|
280
|
+
|
|
281
|
+
// Extract HTML content
|
|
282
|
+
const html = await page.evaluate(() => document.documentElement.outerHTML);
|
|
283
|
+
assert(html.includes('Geneva Documentation'), 'Page should contain Geneva content');
|
|
284
|
+
|
|
285
|
+
// Extract URLs from the same domain
|
|
286
|
+
const urlPattern = /href="(https:\/\/eng\.ms\/[^"]+)"/g;
|
|
287
|
+
const extractedUrls = [];
|
|
288
|
+
let match;
|
|
289
|
+
while ((match = urlPattern.exec(html)) !== null && extractedUrls.length < 5) {
|
|
290
|
+
extractedUrls.push(match[1]);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
assert(extractedUrls.length === 5, `Should extract 5 URLs, got ${extractedUrls.length}`);
|
|
294
|
+
|
|
295
|
+
// Verify all URLs are from eng.ms domain
|
|
296
|
+
for (const url of extractedUrls) {
|
|
297
|
+
const urlHostname = new URL(url).hostname;
|
|
298
|
+
assert(urlHostname === hostname, `All URLs should be from ${hostname}, got ${urlHostname}`);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Load each of the 5 URLs and verify tab reuse
|
|
302
|
+
const reusedPage = domainPages.get(hostname);
|
|
303
|
+
assert(reusedPage === page, 'Should reuse same page for same domain');
|
|
304
|
+
|
|
305
|
+
for (let i = 0; i < extractedUrls.length; i++) {
|
|
306
|
+
await reusedPage.goto(extractedUrls[i]);
|
|
307
|
+
assert(reusedPage.url() === extractedUrls[i], `URL ${i+1} should be loaded: ${extractedUrls[i]}`);
|
|
308
|
+
assert(!reusedPage.isClosed(), `Page should remain open after loading URL ${i+1}`);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
assert(domainPages.size === 1, 'Should still have only 1 domain (eng.ms) in map after all loads');
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
// Summary
|
|
315
|
+
console.log('\n' + '='.repeat(50));
|
|
316
|
+
console.log(`✅ Tests Passed: ${testsPassed}`);
|
|
317
|
+
console.log(`❌ Tests Failed: ${testsFailed}`);
|
|
318
|
+
console.log('='.repeat(50));
|
|
319
|
+
|
|
320
|
+
if (testsFailed > 0) {
|
|
321
|
+
process.exit(1);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Run tests
|
|
326
|
+
runTests().catch(error => {
|
|
327
|
+
console.error('Test suite failed:', error);
|
|
328
|
+
process.exit(1);
|
|
329
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests - REQUIRES REAL CHROME AND USER AUTHENTICATION
|
|
3
|
+
* These tests will actually open Chrome browser and require manual login
|
|
4
|
+
* Run with: node tests/integration.test.js
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { fileURLToPath } from 'url';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import { fetchPage } from '../src/mcp-browser.js';
|
|
10
|
+
|
|
11
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
12
|
+
const __dirname = path.dirname(__filename);
|
|
13
|
+
|
|
14
|
+
// Test framework
|
|
15
|
+
let testsPassed = 0;
|
|
16
|
+
let testsFailed = 0;
|
|
17
|
+
|
|
18
|
+
function assert(condition, message) {
|
|
19
|
+
if (!condition) {
|
|
20
|
+
console.error(`❌ FAILED: ${message}`);
|
|
21
|
+
testsFailed++;
|
|
22
|
+
throw new Error(message);
|
|
23
|
+
} else {
|
|
24
|
+
console.log(`✅ PASSED: ${message}`);
|
|
25
|
+
testsPassed++;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function test(name, fn) {
|
|
30
|
+
console.log(`\n🧪 Test: ${name}`);
|
|
31
|
+
try {
|
|
32
|
+
await fn();
|
|
33
|
+
} catch (error) {
|
|
34
|
+
console.error(` Error: ${error.message}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Integration Tests
|
|
39
|
+
async function runIntegrationTests() {
|
|
40
|
+
console.log('🚀 Starting Integration Tests (REAL CHROME)\n');
|
|
41
|
+
console.log('⚠️ This will open Chrome browser and may require authentication');
|
|
42
|
+
console.log('⚠️ fetchPage function will WAIT for you to complete authentication\n');
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
await test('Should fetch eng.ms page, extract links, and load them (full Copilot workflow)', async () => {
|
|
46
|
+
const url = 'https://eng.ms/docs/products/geneva';
|
|
47
|
+
|
|
48
|
+
// Step 1: Fetch initial page (with auth waiting)
|
|
49
|
+
console.log(` 📄 Step 1: Fetching ${url}`);
|
|
50
|
+
console.log(` ⏳ Function will wait up to 10 minutes for authentication...`);
|
|
51
|
+
console.log(` 💡 Complete login in the browser that opens`);
|
|
52
|
+
|
|
53
|
+
const result = await fetchPage({ url });
|
|
54
|
+
|
|
55
|
+
console.log(` ✅ Result: ${result.success ? 'SUCCESS' : 'FAILED'}`);
|
|
56
|
+
if (result.success) {
|
|
57
|
+
console.log(` 🔗 Final URL: ${result.url}`);
|
|
58
|
+
console.log(` 📄 HTML length: ${result.html?.length || 0} chars`);
|
|
59
|
+
} else {
|
|
60
|
+
console.log(` ❌ Error: ${result.error}`);
|
|
61
|
+
console.log(` 💡 Hint: ${result.hint}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
assert(result.success, 'Should successfully fetch page after authentication');
|
|
65
|
+
assert(result.url.includes('eng.ms'), `URL should be from eng.ms domain, got: ${result.url}`);
|
|
66
|
+
assert(result.html && result.html.length > 0, 'Should return HTML content');
|
|
67
|
+
|
|
68
|
+
// Step 2: Extract ALL links from HTML, then pick 5 randomly
|
|
69
|
+
console.log(`\n 📋 Step 2: Extracting all links from HTML...`);
|
|
70
|
+
|
|
71
|
+
const baseUrl = new URL(result.url);
|
|
72
|
+
const urlPattern = /href=["']([^"']+)["']/g;
|
|
73
|
+
const allUrls = [];
|
|
74
|
+
let match;
|
|
75
|
+
|
|
76
|
+
// Static asset extensions to skip
|
|
77
|
+
const skipExtensions = ['.css', '.js', '.ico', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.woff', '.woff2', '.ttf', '.eot'];
|
|
78
|
+
|
|
79
|
+
// Extract ALL URLs first
|
|
80
|
+
while ((match = urlPattern.exec(result.html)) !== null) {
|
|
81
|
+
let foundUrl = match[1];
|
|
82
|
+
|
|
83
|
+
// Skip anchor links
|
|
84
|
+
if (foundUrl.includes('#')) continue;
|
|
85
|
+
|
|
86
|
+
// Convert relative URLs to absolute
|
|
87
|
+
if (foundUrl.startsWith('/')) {
|
|
88
|
+
foundUrl = `${baseUrl.origin}${foundUrl}`;
|
|
89
|
+
} else if (!foundUrl.startsWith('http')) {
|
|
90
|
+
continue; // Skip other relative URLs
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Skip static assets (check path without query string)
|
|
94
|
+
const urlWithoutQuery = foundUrl.split('?')[0];
|
|
95
|
+
if (skipExtensions.some(ext => urlWithoutQuery.toLowerCase().endsWith(ext))) continue;
|
|
96
|
+
|
|
97
|
+
// Only include eng.ms URLs (pages)
|
|
98
|
+
if (foundUrl.includes('eng.ms')) {
|
|
99
|
+
allUrls.push(foundUrl);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
console.log(` 📊 Total page URLs found: ${allUrls.length}`);
|
|
104
|
+
|
|
105
|
+
// Remove duplicates
|
|
106
|
+
const uniqueUrls = [...new Set(allUrls)];
|
|
107
|
+
console.log(` 🔗 Unique page URLs: ${uniqueUrls.length}`);
|
|
108
|
+
|
|
109
|
+
// Randomly pick 5 URLs
|
|
110
|
+
const shuffled = uniqueUrls.sort(() => Math.random() - 0.5);
|
|
111
|
+
const extractedUrls = shuffled.slice(0, 5);
|
|
112
|
+
|
|
113
|
+
console.log(` 🎲 Randomly selected ${extractedUrls.length} URLs to test:`);
|
|
114
|
+
extractedUrls.forEach((link, i) => console.log(` ${i+1}. ${link}`));
|
|
115
|
+
|
|
116
|
+
assert(extractedUrls.length > 0, `Should extract at least one eng.ms URL, found ${extractedUrls.length}`);
|
|
117
|
+
|
|
118
|
+
// Step 3: Load each extracted URL (tab reuse)
|
|
119
|
+
console.log(`\n 🔄 Step 3: Loading extracted links (using same tab)...`);
|
|
120
|
+
|
|
121
|
+
const linksToTest = extractedUrls.slice(0, Math.min(5, extractedUrls.length));
|
|
122
|
+
for (let i = 0; i < linksToTest.length; i++) {
|
|
123
|
+
const link = linksToTest[i];
|
|
124
|
+
console.log(` 📄 Loading link ${i+1}/${linksToTest.length}: ${link}`);
|
|
125
|
+
|
|
126
|
+
const linkResult = await fetchPage({ url: link });
|
|
127
|
+
|
|
128
|
+
console.log(` ✅ Loaded: ${linkResult.url}`);
|
|
129
|
+
assert(linkResult.success, `Should successfully load link ${i+1}: ${link}`);
|
|
130
|
+
assert(linkResult.html && linkResult.html.length > 0, `Link ${i+1} should return HTML content`);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
} catch (error) {
|
|
135
|
+
console.error('\n❌ Test suite error:', error.message);
|
|
136
|
+
testsFailed++;
|
|
137
|
+
} finally {
|
|
138
|
+
// Summary
|
|
139
|
+
console.log('\n' + '='.repeat(50));
|
|
140
|
+
console.log(`✅ Tests Passed: ${testsPassed}`);
|
|
141
|
+
console.log(`❌ Tests Failed: ${testsFailed}`);
|
|
142
|
+
console.log('='.repeat(50));
|
|
143
|
+
console.log('\n💡 Browser left open for manual inspection');
|
|
144
|
+
|
|
145
|
+
if (testsFailed > 0) {
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Exit immediately without waiting for browser
|
|
150
|
+
process.exit(0);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Run tests
|
|
155
|
+
runIntegrationTests().catch(error => {
|
|
156
|
+
console.error('Test suite failed:', error);
|
|
157
|
+
process.exit(1);
|
|
158
|
+
});
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import { strict as assert } from 'node:assert';
|
|
3
|
+
import { spawn } from 'child_process';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
import { dirname, join } from 'path';
|
|
6
|
+
|
|
7
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
8
|
+
const __dirname = dirname(__filename);
|
|
9
|
+
|
|
10
|
+
describe('MCP Server', () => {
|
|
11
|
+
it('should start and respond to initialize request', async () => {
|
|
12
|
+
const mcpProcess = spawn('node', [join(__dirname, '..', 'src', 'mcp-browser.js')], {
|
|
13
|
+
stdio: ['pipe', 'pipe', 'inherit']
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
const initRequest = {
|
|
18
|
+
jsonrpc: '2.0',
|
|
19
|
+
id: 1,
|
|
20
|
+
method: 'initialize',
|
|
21
|
+
params: {
|
|
22
|
+
protocolVersion: '2024-11-05',
|
|
23
|
+
capabilities: {},
|
|
24
|
+
clientInfo: { name: 'test', version: '1.0' }
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const response = await new Promise((resolve, reject) => {
|
|
29
|
+
const timeout = setTimeout(() => {
|
|
30
|
+
reject(new Error('Server did not respond to initialize request within 5 seconds'));
|
|
31
|
+
}, 5000);
|
|
32
|
+
|
|
33
|
+
let buffer = '';
|
|
34
|
+
mcpProcess.stdout.on('data', (data) => {
|
|
35
|
+
buffer += data.toString();
|
|
36
|
+
const lines = buffer.split('\n');
|
|
37
|
+
|
|
38
|
+
for (const line of lines) {
|
|
39
|
+
if (line.trim()) {
|
|
40
|
+
try {
|
|
41
|
+
const parsed = JSON.parse(line);
|
|
42
|
+
if (parsed.id === 1) {
|
|
43
|
+
clearTimeout(timeout);
|
|
44
|
+
resolve(parsed);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
} catch (e) {
|
|
48
|
+
// Not JSON, continue
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
mcpProcess.on('error', (err) => {
|
|
55
|
+
clearTimeout(timeout);
|
|
56
|
+
reject(err);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
mcpProcess.on('exit', (code) => {
|
|
60
|
+
clearTimeout(timeout);
|
|
61
|
+
reject(new Error(`Server exited with code ${code} before responding`));
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
mcpProcess.stdin.write(JSON.stringify(initRequest) + '\n');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
assert.ok(response.result, 'Initialize response should have result');
|
|
68
|
+
assert.equal(response.result.protocolVersion, '2024-11-05', 'Protocol version should match');
|
|
69
|
+
assert.ok(response.result.serverInfo, 'Server info should be present');
|
|
70
|
+
assert.equal(response.result.serverInfo.name, 'MCPBrowser', 'Server name should be MCPBrowser');
|
|
71
|
+
} finally {
|
|
72
|
+
mcpProcess.kill();
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should respond to tools/list request', async () => {
|
|
77
|
+
const mcpProcess = spawn('node', [join(__dirname, '..', 'src', 'mcp-browser.js')], {
|
|
78
|
+
stdio: ['pipe', 'pipe', 'inherit']
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
// First initialize
|
|
83
|
+
const initRequest = {
|
|
84
|
+
jsonrpc: '2.0',
|
|
85
|
+
id: 1,
|
|
86
|
+
method: 'initialize',
|
|
87
|
+
params: {
|
|
88
|
+
protocolVersion: '2024-11-05',
|
|
89
|
+
capabilities: {},
|
|
90
|
+
clientInfo: { name: 'test', version: '1.0' }
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
mcpProcess.stdin.write(JSON.stringify(initRequest) + '\n');
|
|
95
|
+
|
|
96
|
+
// Wait a bit for initialization
|
|
97
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
98
|
+
|
|
99
|
+
// Then request tools list
|
|
100
|
+
const toolsRequest = {
|
|
101
|
+
jsonrpc: '2.0',
|
|
102
|
+
id: 2,
|
|
103
|
+
method: 'tools/list',
|
|
104
|
+
params: {}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const response = await new Promise((resolve, reject) => {
|
|
108
|
+
const timeout = setTimeout(() => {
|
|
109
|
+
reject(new Error('Server did not respond to tools/list request within 5 seconds'));
|
|
110
|
+
}, 5000);
|
|
111
|
+
|
|
112
|
+
let buffer = '';
|
|
113
|
+
mcpProcess.stdout.on('data', (data) => {
|
|
114
|
+
buffer += data.toString();
|
|
115
|
+
const lines = buffer.split('\n');
|
|
116
|
+
|
|
117
|
+
for (const line of lines) {
|
|
118
|
+
if (line.trim()) {
|
|
119
|
+
try {
|
|
120
|
+
const parsed = JSON.parse(line);
|
|
121
|
+
if (parsed.id === 2) {
|
|
122
|
+
clearTimeout(timeout);
|
|
123
|
+
resolve(parsed);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
} catch (e) {
|
|
127
|
+
// Not JSON, continue
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
mcpProcess.on('error', (err) => {
|
|
134
|
+
clearTimeout(timeout);
|
|
135
|
+
reject(err);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
mcpProcess.stdin.write(JSON.stringify(toolsRequest) + '\n');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
assert.ok(response.result, 'Tools/list response should have result');
|
|
142
|
+
assert.ok(response.result.tools, 'Result should have tools array');
|
|
143
|
+
assert.ok(Array.isArray(response.result.tools), 'Tools should be an array');
|
|
144
|
+
assert.ok(response.result.tools.length > 0, 'Should have at least one tool');
|
|
145
|
+
|
|
146
|
+
const fetchTool = response.result.tools.find(t => t.name === 'fetch_webpage_protected');
|
|
147
|
+
assert.ok(fetchTool, 'Should have fetch_webpage_protected tool');
|
|
148
|
+
assert.ok(fetchTool.description, 'Tool should have description');
|
|
149
|
+
assert.ok(fetchTool.inputSchema, 'Tool should have input schema');
|
|
150
|
+
} finally {
|
|
151
|
+
mcpProcess.kill();
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
});
|