@auxiora/browser 1.0.0

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.
@@ -0,0 +1,198 @@
1
+ import { isIP } from 'node:net';
2
+ import { BLOCKED_PROTOCOLS } from './types.js';
3
+
4
+ interface ValidatorOptions {
5
+ allowedUrls?: string[];
6
+ blockedUrls?: string[];
7
+ }
8
+
9
+ /**
10
+ * Check if an IP address (v4 or v6) falls within private/internal ranges.
11
+ * Uses numeric comparison instead of string prefix matching to prevent
12
+ * bypass via decimal, hex, or octal IP encodings.
13
+ */
14
+ function isPrivateIP(ip: string): boolean {
15
+ // IPv6 checks
16
+ if (ip.includes(':')) {
17
+ const normalized = normalizeIPv6(ip);
18
+ // ::1 (loopback)
19
+ if (normalized === '0000:0000:0000:0000:0000:0000:0000:0001') return true;
20
+ // :: (unspecified)
21
+ if (normalized === '0000:0000:0000:0000:0000:0000:0000:0000') return true;
22
+ // fe80::/10 (link-local)
23
+ if (normalized.startsWith('fe8') || normalized.startsWith('fe9') ||
24
+ normalized.startsWith('fea') || normalized.startsWith('feb')) return true;
25
+ // fc00::/7 (unique local)
26
+ if (normalized.startsWith('fc') || normalized.startsWith('fd')) return true;
27
+ // ::ffff:x.x.x.x (IPv4-mapped IPv6)
28
+ if (normalized.startsWith('0000:0000:0000:0000:0000:ffff:')) {
29
+ const lastTwo = normalized.slice(30); // e.g., "7f00:0001"
30
+ const hi = parseInt(lastTwo.slice(0, 4), 16);
31
+ const lo = parseInt(lastTwo.slice(5, 9), 16);
32
+ const ipv4 = ((hi << 16) | lo) >>> 0;
33
+ return isPrivateIPv4Numeric(ipv4);
34
+ }
35
+ return false;
36
+ }
37
+
38
+ // IPv4: parse to numeric
39
+ const num = parseIPv4ToNumber(ip);
40
+ if (num === null) return false;
41
+ return isPrivateIPv4Numeric(num);
42
+ }
43
+
44
+ /**
45
+ * Parse an IPv4 address string to a 32-bit unsigned number.
46
+ * Handles standard dotted-decimal (e.g. "127.0.0.1").
47
+ */
48
+ function parseIPv4ToNumber(ip: string): number | null {
49
+ const parts = ip.split('.');
50
+ if (parts.length !== 4) return null;
51
+
52
+ let result = 0;
53
+ for (const part of parts) {
54
+ const n = parseInt(part, 10);
55
+ if (isNaN(n) || n < 0 || n > 255) return null;
56
+ result = (result << 8) | n;
57
+ }
58
+ return result >>> 0; // unsigned
59
+ }
60
+
61
+ /**
62
+ * Check if a numeric IPv4 address is in a private/internal range.
63
+ */
64
+ function isPrivateIPv4Numeric(ip: number): boolean {
65
+ // 127.0.0.0/8 (loopback)
66
+ if ((ip >>> 24) === 127) return true;
67
+ // 10.0.0.0/8
68
+ if ((ip >>> 24) === 10) return true;
69
+ // 172.16.0.0/12
70
+ if ((ip >>> 20) === (172 << 4 | 1)) return true; // 0xAC1 = 172.16-31
71
+ // 192.168.0.0/16
72
+ if ((ip >>> 16) === (192 << 8 | 168)) return true; // 0xC0A8
73
+ // 169.254.0.0/16 (link-local)
74
+ if ((ip >>> 16) === (169 << 8 | 254)) return true; // 0xA9FE
75
+ // 0.0.0.0
76
+ if (ip === 0) return true;
77
+ return false;
78
+ }
79
+
80
+ /**
81
+ * Normalize an IPv6 address to its full expanded form.
82
+ */
83
+ function normalizeIPv6(ip: string): string {
84
+ // Handle IPv4-mapped addresses like ::ffff:127.0.0.1
85
+ const v4MappedMatch = ip.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
86
+ if (v4MappedMatch) {
87
+ const v4num = parseIPv4ToNumber(v4MappedMatch[1]);
88
+ if (v4num !== null) {
89
+ const hi = (v4num >>> 16) & 0xffff;
90
+ const lo = v4num & 0xffff;
91
+ return `0000:0000:0000:0000:0000:ffff:${hi.toString(16).padStart(4, '0')}:${lo.toString(16).padStart(4, '0')}`;
92
+ }
93
+ }
94
+
95
+ let parts = ip.split(':');
96
+ // Handle :: expansion
97
+ const emptyIndex = parts.indexOf('');
98
+ if (ip.includes('::')) {
99
+ const before = ip.split('::')[0].split(':').filter(Boolean);
100
+ const after = ip.split('::')[1].split(':').filter(Boolean);
101
+ const missing = 8 - before.length - after.length;
102
+ parts = [...before, ...Array(missing).fill('0'), ...after];
103
+ }
104
+
105
+ return parts.map((p) => (p || '0').padStart(4, '0').toLowerCase()).join(':');
106
+ }
107
+
108
+ /**
109
+ * Check if a hostname looks like a numeric IP (decimal, hex, octal)
110
+ * that could bypass string-based checks.
111
+ */
112
+ function isNumericHostname(hostname: string): boolean {
113
+ // Pure decimal number (e.g., 2130706433 for 127.0.0.1)
114
+ if (/^\d+$/.test(hostname)) return true;
115
+ // Hex number (e.g., 0x7f000001)
116
+ if (/^0x[0-9a-fA-F]+$/i.test(hostname)) return true;
117
+ // Octal parts (e.g., 0177.0.0.1)
118
+ if (/^[0-7]+\./.test(hostname) && hostname.startsWith('0') && !hostname.startsWith('0.')) return true;
119
+ return false;
120
+ }
121
+
122
+ /**
123
+ * Validates a URL for browser navigation.
124
+ * Returns null if valid, or an error message string.
125
+ *
126
+ * Blocks:
127
+ * - Non http/https protocols (file:, javascript:, data:, blob:)
128
+ * - Private/internal IP addresses (127.x, 10.x, 192.168.x, 172.16-31.x, 169.254.x, localhost)
129
+ * - Numeric IP bypasses (decimal, hex, octal encodings)
130
+ * - IPv6-mapped IPv4 addresses (::ffff:127.0.0.1)
131
+ */
132
+ export function validateUrl(url: string, options?: ValidatorOptions): string | null {
133
+ if (!url || typeof url !== 'string') {
134
+ return 'URL must be a non-empty string';
135
+ }
136
+
137
+ let parsed: URL;
138
+ try {
139
+ parsed = new URL(url);
140
+ } catch {
141
+ return 'Invalid URL format';
142
+ }
143
+
144
+ // Check blocked protocols
145
+ if (BLOCKED_PROTOCOLS.includes(parsed.protocol)) {
146
+ return `Blocked protocol: ${parsed.protocol} — only http: and https: are allowed`;
147
+ }
148
+
149
+ // Only allow http and https
150
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
151
+ return `Blocked protocol: ${parsed.protocol} — only http: and https: are allowed`;
152
+ }
153
+
154
+ const hostname = parsed.hostname;
155
+
156
+ // Check blocklist first (takes priority)
157
+ if (options?.blockedUrls?.some((pattern) => hostname.includes(pattern))) {
158
+ return `URL blocked by blocklist: ${hostname}`;
159
+ }
160
+
161
+ // Check if in allowlist (skip private IP check if allowed)
162
+ const isAllowed = options?.allowedUrls?.some((pattern) => hostname.includes(pattern));
163
+ if (isAllowed) {
164
+ return null;
165
+ }
166
+
167
+ // Block numeric IP encodings (decimal, hex, octal) that could bypass string checks
168
+ if (isNumericHostname(hostname)) {
169
+ return `Blocked numeric IP encoding: ${hostname}`;
170
+ }
171
+
172
+ // Block localhost
173
+ if (hostname === 'localhost' || hostname.endsWith('.localhost')) {
174
+ return `Blocked private/internal address: ${hostname}`;
175
+ }
176
+
177
+ // Check if hostname is an IP address
178
+ const ipVersion = isIP(hostname);
179
+ if (ipVersion > 0) {
180
+ // It's a direct IP — check against private ranges numerically
181
+ if (isPrivateIP(hostname)) {
182
+ return `Blocked private/internal address: ${hostname}`;
183
+ }
184
+ }
185
+
186
+ // Check hostnames that resolve to IPv6 loopback patterns
187
+ if (hostname.startsWith('[') && hostname.endsWith(']')) {
188
+ const innerIP = hostname.slice(1, -1);
189
+ if (isPrivateIP(innerIP)) {
190
+ return `Blocked private/internal address: ${hostname}`;
191
+ }
192
+ }
193
+
194
+ return null;
195
+ }
196
+
197
+ // Export for testing
198
+ export { isPrivateIP, parseIPv4ToNumber, isNumericHostname, normalizeIPv6 };
@@ -0,0 +1,185 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { BrowserManager } from '../src/browser-manager.js';
3
+
4
+ // --- Mock factories ---
5
+
6
+ function createMockPage(overrides: Record<string, any> = {}) {
7
+ return {
8
+ isClosed: vi.fn().mockReturnValue(false),
9
+ close: vi.fn().mockResolvedValue(undefined),
10
+ goto: vi.fn().mockResolvedValue(undefined),
11
+ title: vi.fn().mockResolvedValue('Test Page'),
12
+ url: vi.fn().mockReturnValue('https://example.com'),
13
+ content: vi.fn().mockResolvedValue('<html><body><h1>Hello</h1></body></html>'),
14
+ click: vi.fn().mockResolvedValue(undefined),
15
+ fill: vi.fn().mockResolvedValue(undefined),
16
+ screenshot: vi.fn().mockResolvedValue(Buffer.from('fake-png')),
17
+ $: vi.fn().mockResolvedValue(null),
18
+ $$eval: vi.fn().mockResolvedValue([]),
19
+ evaluate: vi.fn().mockResolvedValue({}),
20
+ waitForSelector: vi.fn().mockResolvedValue(undefined),
21
+ setViewportSize: vi.fn().mockResolvedValue(undefined),
22
+ setDefaultNavigationTimeout: vi.fn(),
23
+ setDefaultTimeout: vi.fn(),
24
+ keyboard: {
25
+ press: vi.fn().mockResolvedValue(undefined),
26
+ },
27
+ ...overrides,
28
+ };
29
+ }
30
+
31
+ function createMockContext(overrides: Record<string, any> = {}) {
32
+ const mockPage = createMockPage();
33
+ return {
34
+ newPage: vi.fn().mockResolvedValue(mockPage),
35
+ close: vi.fn().mockResolvedValue(undefined),
36
+ _mockPage: mockPage,
37
+ ...overrides,
38
+ };
39
+ }
40
+
41
+ function createMockBrowser(overrides: Record<string, any> = {}) {
42
+ const mockContext = createMockContext();
43
+ return {
44
+ isConnected: vi.fn().mockReturnValue(true),
45
+ newContext: vi.fn().mockResolvedValue(mockContext),
46
+ close: vi.fn().mockResolvedValue(undefined),
47
+ _mockContext: mockContext,
48
+ ...overrides,
49
+ };
50
+ }
51
+
52
+ // --- Tests ---
53
+
54
+ describe('BrowserManager Actions', () => {
55
+ let mockBrowser: ReturnType<typeof createMockBrowser>;
56
+ let mockPage: ReturnType<typeof createMockPage>;
57
+ let browserFactory: ReturnType<typeof vi.fn>;
58
+ let manager: BrowserManager;
59
+
60
+ beforeEach(async () => {
61
+ mockBrowser = createMockBrowser();
62
+ mockPage = mockBrowser._mockContext._mockPage;
63
+ browserFactory = vi.fn().mockResolvedValue(mockBrowser);
64
+ manager = new BrowserManager({
65
+ browserFactory,
66
+ config: { screenshotDir: '' } as any,
67
+ });
68
+ });
69
+
70
+ describe('navigate', () => {
71
+ it('should return page info after navigation', async () => {
72
+ const result = await manager.navigate('s1', 'https://example.com');
73
+ expect(result.url).toBe('https://example.com');
74
+ expect(result.title).toBe('Test Page');
75
+ expect(result.content).toBeDefined();
76
+ expect(mockPage.goto).toHaveBeenCalledWith('https://example.com', { waitUntil: 'domcontentloaded' });
77
+ });
78
+
79
+ it('should reject blocked URLs', async () => {
80
+ await expect(
81
+ manager.navigate('s1', 'https://evil.com', )
82
+ ).resolves.toBeDefined(); // not blocked by default
83
+
84
+ const blockedManager = new BrowserManager({
85
+ browserFactory,
86
+ config: { blockedUrls: ['evil.com'], screenshotDir: '' } as any,
87
+ });
88
+ await expect(
89
+ blockedManager.navigate('s1', 'https://evil.com')
90
+ ).rejects.toThrow('blocked');
91
+ });
92
+
93
+ it('should reject private IPs', async () => {
94
+ await expect(
95
+ manager.navigate('s1', 'http://127.0.0.1')
96
+ ).rejects.toThrow('private');
97
+ });
98
+ });
99
+
100
+ describe('click', () => {
101
+ it('should call page.click with selector and timeout', async () => {
102
+ await manager.click('s1', '#btn');
103
+ expect(mockPage.click).toHaveBeenCalledWith('#btn', expect.objectContaining({ timeout: expect.any(Number) }));
104
+ });
105
+ });
106
+
107
+ describe('type', () => {
108
+ it('should fill input with text', async () => {
109
+ await manager.type('s1', '#input', 'hello');
110
+ expect(mockPage.fill).toHaveBeenCalledWith('#input', 'hello');
111
+ expect(mockPage.keyboard.press).not.toHaveBeenCalled();
112
+ });
113
+
114
+ it('should press Enter when requested', async () => {
115
+ await manager.type('s1', '#input', 'hello', true);
116
+ expect(mockPage.fill).toHaveBeenCalledWith('#input', 'hello');
117
+ expect(mockPage.keyboard.press).toHaveBeenCalledWith('Enter');
118
+ });
119
+ });
120
+
121
+ describe('extract', () => {
122
+ it('should return elements by selector', async () => {
123
+ const mockElements = [
124
+ { text: 'Hello', tagName: 'h1', attributes: { class: 'title' } },
125
+ ];
126
+ mockPage.$$eval.mockResolvedValue(mockElements);
127
+
128
+ const result = await manager.extract('s1', 'h1');
129
+ expect(result.selector).toBe('h1');
130
+ expect(result.elements).toEqual(mockElements);
131
+ });
132
+ });
133
+
134
+ describe('wait', () => {
135
+ it('should wait for selector', async () => {
136
+ await manager.wait('s1', '.loaded');
137
+ expect(mockPage.waitForSelector).toHaveBeenCalledWith('.loaded', expect.objectContaining({ timeout: expect.any(Number) }));
138
+ });
139
+
140
+ it('should wait for fixed delay', async () => {
141
+ const start = Date.now();
142
+ await manager.wait('s1', 50);
143
+ const elapsed = Date.now() - start;
144
+ expect(elapsed).toBeGreaterThanOrEqual(40); // allow small timing margin
145
+ });
146
+ });
147
+
148
+ describe('runScript', () => {
149
+ it('should return JSON result', async () => {
150
+ mockPage.evaluate.mockResolvedValue({ answer: 42 });
151
+ const result = await manager.runScript('s1', 'document.title');
152
+ expect(result).toBe('{"answer":42}');
153
+ });
154
+
155
+ it('should reject oversized results', async () => {
156
+ const bigObj = { data: 'x'.repeat(200_000) };
157
+ mockPage.evaluate.mockResolvedValue(bigObj);
158
+ await expect(manager.runScript('s1', 'big()')).rejects.toThrow('Result too large');
159
+ });
160
+ });
161
+
162
+ describe('screenshot', () => {
163
+ it('should capture full-page screenshot as base64', async () => {
164
+ const fakeBuffer = Buffer.from('png-data');
165
+ mockPage.screenshot.mockResolvedValue(fakeBuffer);
166
+
167
+ const result = await manager.screenshot('s1');
168
+ expect(result.base64).toBe(fakeBuffer.toString('base64'));
169
+ expect(mockPage.screenshot).toHaveBeenCalledWith(expect.objectContaining({ fullPage: true, type: 'png' }));
170
+ });
171
+ });
172
+
173
+ describe('browse', () => {
174
+ it('should return mutation message for write tasks', async () => {
175
+ const result = await manager.browse('s1', 'Click the login button');
176
+ expect(result.result).toContain('interactions');
177
+ expect(result.steps).toEqual([]);
178
+ });
179
+
180
+ it('should handle read tasks', async () => {
181
+ const result = await manager.browse('s1', 'Find the pricing information');
182
+ expect(result.result).toContain('Browse task queued');
183
+ });
184
+ });
185
+ });
@@ -0,0 +1,142 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { BrowserManager } from '../src/browser-manager.js';
3
+
4
+ // --- Mock factories ---
5
+
6
+ function createMockPage(overrides: Record<string, any> = {}) {
7
+ return {
8
+ isClosed: vi.fn().mockReturnValue(false),
9
+ close: vi.fn().mockResolvedValue(undefined),
10
+ goto: vi.fn().mockResolvedValue(undefined),
11
+ title: vi.fn().mockResolvedValue('Test Page'),
12
+ url: vi.fn().mockReturnValue('https://example.com'),
13
+ content: vi.fn().mockResolvedValue('<html><body><h1>Hello</h1></body></html>'),
14
+ click: vi.fn().mockResolvedValue(undefined),
15
+ fill: vi.fn().mockResolvedValue(undefined),
16
+ screenshot: vi.fn().mockResolvedValue(Buffer.from('fake-png')),
17
+ $: vi.fn().mockResolvedValue(null),
18
+ $$eval: vi.fn().mockResolvedValue([]),
19
+ evaluate: vi.fn().mockResolvedValue({}),
20
+ waitForSelector: vi.fn().mockResolvedValue(undefined),
21
+ setViewportSize: vi.fn().mockResolvedValue(undefined),
22
+ setDefaultNavigationTimeout: vi.fn(),
23
+ setDefaultTimeout: vi.fn(),
24
+ keyboard: {
25
+ press: vi.fn().mockResolvedValue(undefined),
26
+ },
27
+ ...overrides,
28
+ };
29
+ }
30
+
31
+ function createMockContext(overrides: Record<string, any> = {}) {
32
+ const mockPage = createMockPage();
33
+ return {
34
+ newPage: vi.fn().mockResolvedValue(mockPage),
35
+ close: vi.fn().mockResolvedValue(undefined),
36
+ _mockPage: mockPage,
37
+ ...overrides,
38
+ };
39
+ }
40
+
41
+ function createMockBrowser(overrides: Record<string, any> = {}) {
42
+ const mockContext = createMockContext();
43
+ return {
44
+ isConnected: vi.fn().mockReturnValue(true),
45
+ newContext: vi.fn().mockResolvedValue(mockContext),
46
+ close: vi.fn().mockResolvedValue(undefined),
47
+ _mockContext: mockContext,
48
+ ...overrides,
49
+ };
50
+ }
51
+
52
+ // --- Tests ---
53
+
54
+ describe('BrowserManager', () => {
55
+ let mockBrowser: ReturnType<typeof createMockBrowser>;
56
+ let browserFactory: ReturnType<typeof vi.fn>;
57
+ let manager: BrowserManager;
58
+
59
+ beforeEach(() => {
60
+ mockBrowser = createMockBrowser();
61
+ browserFactory = vi.fn().mockResolvedValue(mockBrowser);
62
+ manager = new BrowserManager({
63
+ browserFactory,
64
+ config: { screenshotDir: '' } as any,
65
+ });
66
+ });
67
+
68
+ describe('launch and shutdown', () => {
69
+ it('should launch browser on first use', async () => {
70
+ await manager.launch();
71
+ expect(browserFactory).toHaveBeenCalledTimes(1);
72
+ expect(mockBrowser.newContext).toHaveBeenCalledTimes(1);
73
+ });
74
+
75
+ it('should not re-launch if already running', async () => {
76
+ await manager.launch();
77
+ await manager.launch();
78
+ expect(browserFactory).toHaveBeenCalledTimes(1);
79
+ });
80
+
81
+ it('should close all pages and browser on shutdown', async () => {
82
+ await manager.launch();
83
+ const page = await manager.getPage('session-1');
84
+ await manager.shutdown();
85
+
86
+ expect(page.close).toHaveBeenCalled();
87
+ expect(mockBrowser.close).toHaveBeenCalled();
88
+ expect(manager.getPageCount()).toBe(0);
89
+ });
90
+ });
91
+
92
+ describe('page lifecycle', () => {
93
+ it('should create new page for new session', async () => {
94
+ const page = await manager.getPage('session-1');
95
+ expect(page).toBeDefined();
96
+ expect(page.setViewportSize).toHaveBeenCalled();
97
+ expect(manager.getPageCount()).toBe(1);
98
+ });
99
+
100
+ it('should reuse existing page for same session', async () => {
101
+ const page1 = await manager.getPage('session-1');
102
+ const page2 = await manager.getPage('session-1');
103
+ expect(page1).toBe(page2);
104
+ expect(manager.getPageCount()).toBe(1);
105
+ });
106
+
107
+ it('should close a specific page', async () => {
108
+ const page = await manager.getPage('session-1');
109
+ await manager.closePage('session-1');
110
+ expect(page.close).toHaveBeenCalled();
111
+ expect(manager.getPageCount()).toBe(0);
112
+ });
113
+
114
+ it('should enforce max concurrent pages', async () => {
115
+ const limitedManager = new BrowserManager({
116
+ browserFactory,
117
+ config: { maxConcurrentPages: 2, screenshotDir: '' } as any,
118
+ });
119
+
120
+ await limitedManager.getPage('s1');
121
+ await limitedManager.getPage('s2');
122
+ await expect(limitedManager.getPage('s3')).rejects.toThrow('Max concurrent pages');
123
+ });
124
+ });
125
+
126
+ describe('crash recovery', () => {
127
+ it('should re-launch browser if disconnected', async () => {
128
+ await manager.getPage('session-1');
129
+ expect(browserFactory).toHaveBeenCalledTimes(1);
130
+
131
+ // Simulate browser crash
132
+ mockBrowser.isConnected.mockReturnValue(false);
133
+
134
+ // Create a new mock browser for re-launch
135
+ const newMockBrowser = createMockBrowser();
136
+ browserFactory.mockResolvedValue(newMockBrowser);
137
+
138
+ await manager.getPage('session-2');
139
+ expect(browserFactory).toHaveBeenCalledTimes(2);
140
+ });
141
+ });
142
+ });
@@ -0,0 +1,141 @@
1
+ import { describe, it, expect, afterEach, vi } from 'vitest';
2
+ import { BrowserManager } from '../src/browser-manager.js';
3
+ import { DEFAULT_BROWSER_CONFIG } from '../src/types.js';
4
+ import { validateUrl } from '../src/url-validator.js';
5
+
6
+ function createMockPage(overrides: any = {}) {
7
+ return {
8
+ goto: vi.fn().mockResolvedValue(undefined),
9
+ title: vi.fn().mockResolvedValue(overrides.title || 'Test Page'),
10
+ content: vi.fn().mockResolvedValue(
11
+ overrides.html || '<html><body><h1>Test</h1><p>Content here</p></body></html>'
12
+ ),
13
+ url: vi.fn().mockReturnValue(overrides.url || 'https://example.com'),
14
+ click: vi.fn().mockResolvedValue(undefined),
15
+ fill: vi.fn().mockResolvedValue(undefined),
16
+ keyboard: { press: vi.fn().mockResolvedValue(undefined) },
17
+ screenshot: vi.fn().mockResolvedValue(Buffer.from('screenshot-data')),
18
+ $: vi.fn().mockResolvedValue(null),
19
+ $$eval: vi.fn().mockResolvedValue(
20
+ overrides.elements || [
21
+ { text: 'Story 1', tagName: 'a', attributes: { href: '/story/1' } },
22
+ { text: 'Story 2', tagName: 'a', attributes: { href: '/story/2' } },
23
+ ]
24
+ ),
25
+ waitForSelector: vi.fn().mockResolvedValue(undefined),
26
+ evaluate: vi.fn().mockResolvedValue(overrides.evalResult || null),
27
+ close: vi.fn().mockResolvedValue(undefined),
28
+ isClosed: vi.fn().mockReturnValue(false),
29
+ setViewportSize: vi.fn().mockResolvedValue(undefined),
30
+ setDefaultNavigationTimeout: vi.fn(),
31
+ setDefaultTimeout: vi.fn(),
32
+ };
33
+ }
34
+
35
+ function createMockBrowser(page: any) {
36
+ const context = {
37
+ newPage: vi.fn().mockResolvedValue(page),
38
+ close: vi.fn().mockResolvedValue(undefined),
39
+ };
40
+ return {
41
+ newContext: vi.fn().mockResolvedValue(context),
42
+ isConnected: vi.fn().mockReturnValue(true),
43
+ close: vi.fn().mockResolvedValue(undefined),
44
+ contexts: vi.fn().mockReturnValue([context]),
45
+ };
46
+ }
47
+
48
+ describe('Browser integration', () => {
49
+ let manager: BrowserManager;
50
+
51
+ afterEach(async () => {
52
+ if (manager) await manager.shutdown();
53
+ });
54
+
55
+ it('should navigate then extract then screenshot flow', async () => {
56
+ const mockPage = createMockPage({
57
+ title: 'Hacker News',
58
+ url: 'https://news.ycombinator.com',
59
+ });
60
+ const mockBrowser = createMockBrowser(mockPage);
61
+
62
+ manager = new BrowserManager({
63
+ config: { ...DEFAULT_BROWSER_CONFIG, screenshotDir: '' },
64
+ browserFactory: vi.fn().mockResolvedValue(mockBrowser),
65
+ });
66
+
67
+ // Navigate
68
+ const navResult = await manager.navigate('s1', 'https://news.ycombinator.com');
69
+ expect(navResult.title).toBe('Hacker News');
70
+ expect(navResult.url).toBe('https://news.ycombinator.com');
71
+
72
+ // Extract
73
+ const extractResult = await manager.extract('s1', '.storylink');
74
+ expect(extractResult.elements).toHaveLength(2);
75
+ expect(extractResult.elements[0].text).toBe('Story 1');
76
+
77
+ // Screenshot
78
+ const screenshotResult = await manager.screenshot('s1');
79
+ expect(screenshotResult.base64).toBeDefined();
80
+ expect(screenshotResult.base64.length).toBeGreaterThan(0);
81
+ });
82
+
83
+ it('should isolate multi-session pages', async () => {
84
+ const page1 = createMockPage({ title: 'Page 1', url: 'https://site1.com' });
85
+ const page2 = createMockPage({ title: 'Page 2', url: 'https://site2.com' });
86
+
87
+ let callCount = 0;
88
+ const context = {
89
+ newPage: vi.fn().mockImplementation(async () => {
90
+ callCount++;
91
+ return callCount === 1 ? page1 : page2;
92
+ }),
93
+ close: vi.fn().mockResolvedValue(undefined),
94
+ };
95
+
96
+ const mockBrowser = {
97
+ newContext: vi.fn().mockResolvedValue(context),
98
+ isConnected: vi.fn().mockReturnValue(true),
99
+ close: vi.fn().mockResolvedValue(undefined),
100
+ contexts: vi.fn().mockReturnValue([context]),
101
+ };
102
+
103
+ manager = new BrowserManager({
104
+ config: DEFAULT_BROWSER_CONFIG,
105
+ browserFactory: vi.fn().mockResolvedValue(mockBrowser),
106
+ });
107
+
108
+ const nav1 = await manager.navigate('session-a', 'https://site1.com');
109
+ const nav2 = await manager.navigate('session-b', 'https://site2.com');
110
+
111
+ expect(nav1.title).toBe('Page 1');
112
+ expect(nav2.title).toBe('Page 2');
113
+ expect(manager.getPageCount()).toBe(2);
114
+ });
115
+
116
+ it('should enforce URL validation throughout the stack', () => {
117
+ expect(validateUrl('file:///etc/passwd')).not.toBeNull();
118
+ expect(validateUrl('javascript:alert(1)')).not.toBeNull();
119
+ expect(validateUrl('http://127.0.0.1')).not.toBeNull();
120
+ expect(validateUrl('http://10.0.0.1:8080')).not.toBeNull();
121
+
122
+ expect(validateUrl('https://example.com')).toBeNull();
123
+ expect(validateUrl('https://news.ycombinator.com')).toBeNull();
124
+ });
125
+
126
+ it('should handle browse mutation detection', async () => {
127
+ const mockPage = createMockPage();
128
+ const mockBrowser = createMockBrowser(mockPage);
129
+
130
+ manager = new BrowserManager({
131
+ config: DEFAULT_BROWSER_CONFIG,
132
+ browserFactory: vi.fn().mockResolvedValue(mockBrowser),
133
+ });
134
+
135
+ const mutationResult = await manager.browse('s1', 'click the buy button');
136
+ expect(mutationResult.result).toContain('primitive browser tools');
137
+
138
+ const readResult = await manager.browse('s1', 'get the price of BTC');
139
+ expect(readResult.result).toBeDefined();
140
+ });
141
+ });