@askjo/camoufox-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,149 @@
1
+ const { startJoBrowser, stopJoBrowser, getServerUrl } = require('../helpers/startJoBrowser');
2
+ const { startTestSite, stopTestSite, getTestSiteUrl } = require('../helpers/testSite');
3
+ const { createClient } = require('../helpers/client');
4
+
5
+ describe('Tab Lifecycle', () => {
6
+ let serverUrl;
7
+ let testSiteUrl;
8
+
9
+ beforeAll(async () => {
10
+ const port = await startJoBrowser();
11
+ serverUrl = getServerUrl();
12
+
13
+ const testPort = await startTestSite();
14
+ testSiteUrl = getTestSiteUrl();
15
+ }, 120000);
16
+
17
+ afterAll(async () => {
18
+ await stopTestSite();
19
+ await stopJoBrowser();
20
+ }, 30000);
21
+
22
+ test('health check returns camoufox engine', async () => {
23
+ const client = createClient(serverUrl);
24
+ const health = await client.health();
25
+
26
+ expect(health.ok).toBe(true);
27
+ expect(health.engine).toBe('camoufox');
28
+ });
29
+
30
+ test('create tab without URL', async () => {
31
+ const client = createClient(serverUrl);
32
+
33
+ try {
34
+ const result = await client.createTab();
35
+
36
+ expect(result.tabId).toBeDefined();
37
+ expect(typeof result.tabId).toBe('string');
38
+ expect(result.url).toBe('about:blank');
39
+ } finally {
40
+ await client.cleanup();
41
+ }
42
+ });
43
+
44
+ test('create tab with URL', async () => {
45
+ const client = createClient(serverUrl);
46
+
47
+ try {
48
+ const result = await client.createTab(`${testSiteUrl}/pageA`);
49
+
50
+ expect(result.tabId).toBeDefined();
51
+ expect(result.url).toContain('/pageA');
52
+
53
+ const snapshot = await client.getSnapshot(result.tabId);
54
+ expect(snapshot.snapshot).toContain('Page A');
55
+ } finally {
56
+ await client.cleanup();
57
+ }
58
+ });
59
+
60
+ test('create multiple tabs in same group', async () => {
61
+ const client = createClient(serverUrl);
62
+
63
+ try {
64
+ const tab1 = await client.createTab(`${testSiteUrl}/pageA`);
65
+ const tab2 = await client.createTab(`${testSiteUrl}/pageB`);
66
+
67
+ expect(tab1.tabId).not.toBe(tab2.tabId);
68
+
69
+ const snapshot1 = await client.getSnapshot(tab1.tabId);
70
+ const snapshot2 = await client.getSnapshot(tab2.tabId);
71
+
72
+ expect(snapshot1.snapshot).toContain('Page A');
73
+ expect(snapshot2.snapshot).toContain('Page B');
74
+ } finally {
75
+ await client.cleanup();
76
+ }
77
+ });
78
+
79
+ test('close individual tab', async () => {
80
+ const client = createClient(serverUrl);
81
+
82
+ try {
83
+ const tab1 = await client.createTab(`${testSiteUrl}/pageA`);
84
+ const tab2 = await client.createTab(`${testSiteUrl}/pageB`);
85
+
86
+ await client.closeTab(tab1.tabId);
87
+
88
+ // Tab 1 should be gone
89
+ await expect(client.getSnapshot(tab1.tabId)).rejects.toThrow();
90
+
91
+ // Tab 2 should still work
92
+ const snapshot2 = await client.getSnapshot(tab2.tabId);
93
+ expect(snapshot2.snapshot).toContain('Page B');
94
+ } finally {
95
+ await client.cleanup();
96
+ }
97
+ });
98
+
99
+ test('close tab group', async () => {
100
+ const client = createClient(serverUrl);
101
+
102
+ try {
103
+ const tab1 = await client.createTab(`${testSiteUrl}/pageA`);
104
+ const tab2 = await client.createTab(`${testSiteUrl}/pageB`);
105
+
106
+ await client.closeTabGroup();
107
+
108
+ // Both tabs should be gone
109
+ await expect(client.getSnapshot(tab1.tabId)).rejects.toThrow();
110
+ await expect(client.getSnapshot(tab2.tabId)).rejects.toThrow();
111
+ } finally {
112
+ await client.cleanup();
113
+ }
114
+ });
115
+
116
+ test('close session clears all tabs', async () => {
117
+ const client = createClient(serverUrl);
118
+
119
+ const tab = await client.createTab(`${testSiteUrl}/pageA`);
120
+
121
+ await client.closeSession();
122
+
123
+ // Tab should be gone after session close
124
+ await expect(client.getSnapshot(tab.tabId)).rejects.toThrow();
125
+ });
126
+
127
+ test('tab stats are tracked correctly', async () => {
128
+ const client = createClient(serverUrl);
129
+
130
+ try {
131
+ const { tabId } = await client.createTab(`${testSiteUrl}/pageA`);
132
+
133
+ // Make some operations
134
+ await client.getSnapshot(tabId);
135
+ await client.navigate(tabId, `${testSiteUrl}/pageB`);
136
+ await client.getSnapshot(tabId);
137
+
138
+ const stats = await client.getStats(tabId);
139
+
140
+ expect(stats.tabId).toBe(tabId);
141
+ expect(stats.listItemId).toBe(client.listItemId);
142
+ expect(stats.url).toContain('/pageB');
143
+ expect(stats.toolCalls).toBeGreaterThan(0);
144
+ expect(stats.visitedUrls).toContain(`${testSiteUrl}/pageA`);
145
+ } finally {
146
+ await client.cleanup();
147
+ }
148
+ });
149
+ });
@@ -0,0 +1,147 @@
1
+ const { startJoBrowser, stopJoBrowser, getServerUrl } = require('../helpers/startJoBrowser');
2
+ const { startTestSite, stopTestSite, getTestSiteUrl } = require('../helpers/testSite');
3
+ const { createClient } = require('../helpers/client');
4
+
5
+ describe('Typing and Enter', () => {
6
+ let serverUrl;
7
+ let testSiteUrl;
8
+
9
+ beforeAll(async () => {
10
+ const port = await startJoBrowser();
11
+ serverUrl = getServerUrl();
12
+
13
+ const testPort = await startTestSite();
14
+ testSiteUrl = getTestSiteUrl();
15
+ }, 120000);
16
+
17
+ afterAll(async () => {
18
+ await stopTestSite();
19
+ await stopJoBrowser();
20
+ }, 30000);
21
+
22
+ test('type text into input field', async () => {
23
+ const client = createClient(serverUrl);
24
+
25
+ try {
26
+ const { tabId } = await client.createTab(`${testSiteUrl}/typing`);
27
+
28
+ // Type using selector
29
+ const result = await client.type(tabId, {
30
+ selector: '#input',
31
+ text: 'Hello World'
32
+ });
33
+
34
+ expect(result.ok).toBe(true);
35
+
36
+ // Verify the text appears in preview
37
+ const snapshot = await client.waitForSnapshotContains(tabId, 'Preview: Hello World');
38
+ expect(snapshot.snapshot).toContain('Hello World');
39
+ } finally {
40
+ await client.cleanup();
41
+ }
42
+ });
43
+
44
+ test('type text using ref', async () => {
45
+ const client = createClient(serverUrl);
46
+
47
+ try {
48
+ const { tabId } = await client.createTab(`${testSiteUrl}/typing`);
49
+
50
+ // Get snapshot to find the input ref
51
+ const snapshot = await client.getSnapshot(tabId);
52
+
53
+ // Find a ref for the textbox (look for pattern like [e1] textbox)
54
+ const match = snapshot.snapshot.match(/\[(e\d+)\].*textbox/i);
55
+ if (!match) {
56
+ // Try by selector if ref not found
57
+ await client.type(tabId, { selector: '#input', text: 'Test via selector' });
58
+ } else {
59
+ const ref = match[1];
60
+ await client.type(tabId, { ref, text: 'Test via ref' });
61
+ }
62
+
63
+ const updatedSnapshot = await client.getSnapshot(tabId);
64
+ expect(updatedSnapshot.snapshot).toMatch(/Preview: Test via (ref|selector)/);
65
+ } finally {
66
+ await client.cleanup();
67
+ }
68
+ });
69
+
70
+ test('type and press Enter navigates page', async () => {
71
+ const client = createClient(serverUrl);
72
+
73
+ try {
74
+ const { tabId } = await client.createTab(`${testSiteUrl}/enter`);
75
+
76
+ // Type and press Enter
77
+ await client.type(tabId, {
78
+ selector: '#searchInput',
79
+ text: 'test query',
80
+ pressEnter: true
81
+ });
82
+
83
+ // Wait for navigation to /entered
84
+ const snapshot = await client.waitForUrl(tabId, '/entered');
85
+
86
+ expect(snapshot.url).toContain('/entered');
87
+ expect(snapshot.url).toContain('value=test%20query');
88
+ expect(snapshot.snapshot).toContain('Entered: test query');
89
+ } finally {
90
+ await client.cleanup();
91
+ }
92
+ });
93
+
94
+ test('type without Enter does not navigate', async () => {
95
+ const client = createClient(serverUrl);
96
+
97
+ try {
98
+ const { tabId } = await client.createTab(`${testSiteUrl}/enter`);
99
+
100
+ // Type without pressing Enter
101
+ await client.type(tabId, {
102
+ selector: '#searchInput',
103
+ text: 'should not navigate',
104
+ pressEnter: false
105
+ });
106
+
107
+ // Wait a bit to ensure no navigation happened
108
+ await new Promise(r => setTimeout(r, 500));
109
+
110
+ const snapshot = await client.getSnapshot(tabId);
111
+ expect(snapshot.url).toContain('/enter');
112
+ expect(snapshot.url).not.toContain('/entered');
113
+ } finally {
114
+ await client.cleanup();
115
+ }
116
+ });
117
+
118
+ test('type replaces existing content', async () => {
119
+ const client = createClient(serverUrl);
120
+
121
+ try {
122
+ const { tabId } = await client.createTab(`${testSiteUrl}/typing`);
123
+
124
+ // Type first text
125
+ await client.type(tabId, {
126
+ selector: '#input',
127
+ text: 'First text'
128
+ });
129
+
130
+ let snapshot = await client.waitForSnapshotContains(tabId, 'Preview: First text');
131
+ expect(snapshot.snapshot).toContain('First text');
132
+
133
+ // Type second text (should replace due to clear behavior)
134
+ await client.type(tabId, {
135
+ selector: '#input',
136
+ text: 'Second text',
137
+ clear: true
138
+ });
139
+
140
+ snapshot = await client.waitForSnapshotContains(tabId, 'Preview: Second text');
141
+ expect(snapshot.snapshot).toContain('Second text');
142
+ expect(snapshot.snapshot).not.toContain('First text');
143
+ } finally {
144
+ await client.cleanup();
145
+ }
146
+ });
147
+ });
@@ -0,0 +1,222 @@
1
+ const crypto = require('crypto');
2
+
3
+ class BrowserClient {
4
+ constructor(baseUrl) {
5
+ this.baseUrl = baseUrl;
6
+ this.userId = crypto.randomUUID();
7
+ this.listItemId = crypto.randomUUID();
8
+ this.tabs = [];
9
+ this.timeout = 30000; // 30 second default timeout
10
+ }
11
+
12
+ async request(method, path, body = null, options = {}) {
13
+ const url = `${this.baseUrl}${path}`;
14
+ const controller = new AbortController();
15
+ const timeoutId = setTimeout(() => controller.abort(), options.timeout || this.timeout);
16
+
17
+ try {
18
+ const fetchOptions = {
19
+ method,
20
+ headers: { 'Content-Type': 'application/json' },
21
+ signal: controller.signal
22
+ };
23
+
24
+ if (body) {
25
+ fetchOptions.body = JSON.stringify(body);
26
+ }
27
+
28
+ const response = await fetch(url, fetchOptions);
29
+ clearTimeout(timeoutId);
30
+
31
+ const contentType = response.headers.get('content-type');
32
+ if (contentType?.includes('application/json')) {
33
+ const data = await response.json();
34
+ if (!response.ok) {
35
+ const error = new Error(data.error || `HTTP ${response.status}`);
36
+ error.status = response.status;
37
+ error.data = data;
38
+ throw error;
39
+ }
40
+ return data;
41
+ } else if (contentType?.includes('image/')) {
42
+ return await response.arrayBuffer();
43
+ } else {
44
+ return await response.text();
45
+ }
46
+ } catch (err) {
47
+ clearTimeout(timeoutId);
48
+ if (err.name === 'AbortError') {
49
+ throw new Error(`Request timeout after ${options.timeout || this.timeout}ms: ${method} ${path}`);
50
+ }
51
+ throw err;
52
+ }
53
+ }
54
+
55
+ // Health check
56
+ async health() {
57
+ return this.request('GET', '/health');
58
+ }
59
+
60
+ // Tab management
61
+ async createTab(url = null) {
62
+ const body = { userId: this.userId, listItemId: this.listItemId };
63
+ if (url) body.url = url;
64
+
65
+ const result = await this.request('POST', '/tabs', body);
66
+ if (result.tabId) {
67
+ this.tabs.push(result.tabId);
68
+ }
69
+ return result;
70
+ }
71
+
72
+ async navigate(tabId, urlOrMacro) {
73
+ // Support both regular URLs and @macro syntax
74
+ if (urlOrMacro.startsWith('@')) {
75
+ const [macro, ...queryParts] = urlOrMacro.split(' ');
76
+ const query = queryParts.join(' ');
77
+ return this.request('POST', `/tabs/${tabId}/navigate`, { userId: this.userId, macro, query });
78
+ }
79
+ return this.request('POST', `/tabs/${tabId}/navigate`, { userId: this.userId, url: urlOrMacro });
80
+ }
81
+
82
+ async getSnapshot(tabId) {
83
+ return this.request('GET', `/tabs/${tabId}/snapshot?userId=${this.userId}`);
84
+ }
85
+
86
+ async click(tabId, options) {
87
+ return this.request('POST', `/tabs/${tabId}/click`, { userId: this.userId, ...options });
88
+ }
89
+
90
+ async type(tabId, options) {
91
+ const { pressEnter, clear, ...typeOptions } = options;
92
+
93
+ // Handle clear by selecting all first
94
+ if (clear && (options.selector || options.ref)) {
95
+ // Click to focus, then select all and type to replace
96
+ await this.click(tabId, { selector: options.selector, ref: options.ref });
97
+ await this.press(tabId, 'Control+a');
98
+ }
99
+
100
+ const result = await this.request('POST', `/tabs/${tabId}/type`, { userId: this.userId, ...typeOptions });
101
+
102
+ // Handle Enter key press after typing
103
+ if (pressEnter) {
104
+ await this.press(tabId, 'Enter');
105
+ }
106
+
107
+ return result;
108
+ }
109
+
110
+ async press(tabId, key) {
111
+ return this.request('POST', `/tabs/${tabId}/press`, { userId: this.userId, key });
112
+ }
113
+
114
+ async scroll(tabId, options) {
115
+ return this.request('POST', `/tabs/${tabId}/scroll`, { userId: this.userId, ...options });
116
+ }
117
+
118
+ async back(tabId) {
119
+ return this.request('POST', `/tabs/${tabId}/back`, { userId: this.userId });
120
+ }
121
+
122
+ async forward(tabId) {
123
+ return this.request('POST', `/tabs/${tabId}/forward`, { userId: this.userId });
124
+ }
125
+
126
+ async refresh(tabId) {
127
+ return this.request('POST', `/tabs/${tabId}/refresh`, { userId: this.userId });
128
+ }
129
+
130
+ async getLinks(tabId, options = {}) {
131
+ const params = new URLSearchParams({ userId: this.userId });
132
+ if (options.limit) params.append('limit', options.limit);
133
+ if (options.offset) params.append('offset', options.offset);
134
+ return this.request('GET', `/tabs/${tabId}/links?${params}`);
135
+ }
136
+
137
+ async getStats(tabId) {
138
+ return this.request('GET', `/tabs/${tabId}/stats?userId=${this.userId}`);
139
+ }
140
+
141
+ async screenshot(tabId, fullPage = false) {
142
+ return this.request('GET', `/tabs/${tabId}/screenshot?userId=${this.userId}&fullPage=${fullPage}`);
143
+ }
144
+
145
+ async closeTab(tabId) {
146
+ const result = await this.request('DELETE', `/tabs/${tabId}`, { userId: this.userId });
147
+ this.tabs = this.tabs.filter(t => t !== tabId);
148
+ return result;
149
+ }
150
+
151
+ async closeTabGroup(listItemId = null) {
152
+ return this.request('DELETE', `/tabs/group/${listItemId || this.listItemId}`, { userId: this.userId });
153
+ }
154
+
155
+ async closeSession() {
156
+ return this.request('DELETE', `/sessions/${this.userId}`);
157
+ }
158
+
159
+ // Cleanup all tabs created by this client
160
+ async cleanup() {
161
+ for (const tabId of [...this.tabs]) {
162
+ try {
163
+ await this.closeTab(tabId);
164
+ } catch (e) {
165
+ // Tab may already be closed
166
+ }
167
+ }
168
+ try {
169
+ await this.closeSession();
170
+ } catch (e) {
171
+ // Session may already be closed
172
+ }
173
+ }
174
+
175
+ // Wait for snapshot to contain specific text (polling)
176
+ async waitForSnapshotContains(tabId, text, options = {}) {
177
+ const maxWait = options.maxWait || 10000;
178
+ const interval = options.interval || 500;
179
+ const startTime = Date.now();
180
+
181
+ while (Date.now() - startTime < maxWait) {
182
+ const snapshot = await this.getSnapshot(tabId);
183
+ if (snapshot.snapshot && snapshot.snapshot.includes(text)) {
184
+ return snapshot;
185
+ }
186
+ await new Promise(r => setTimeout(r, interval));
187
+ }
188
+
189
+ throw new Error(`Timeout waiting for snapshot to contain "${text}"`);
190
+ }
191
+
192
+ // Wait for URL to match pattern
193
+ async waitForUrl(tabId, pattern, options = {}) {
194
+ const maxWait = options.maxWait || 10000;
195
+ const interval = options.interval || 500;
196
+ const startTime = Date.now();
197
+
198
+ while (Date.now() - startTime < maxWait) {
199
+ const snapshot = await this.getSnapshot(tabId);
200
+ const url = snapshot.url;
201
+
202
+ if (typeof pattern === 'string' && url.includes(pattern)) {
203
+ return snapshot;
204
+ } else if (pattern instanceof RegExp && pattern.test(url)) {
205
+ return snapshot;
206
+ }
207
+
208
+ await new Promise(r => setTimeout(r, interval));
209
+ }
210
+
211
+ throw new Error(`Timeout waiting for URL to match ${pattern}`);
212
+ }
213
+ }
214
+
215
+ function createClient(baseUrl) {
216
+ return new BrowserClient(baseUrl);
217
+ }
218
+
219
+ module.exports = {
220
+ BrowserClient,
221
+ createClient
222
+ };
@@ -0,0 +1,95 @@
1
+ const { spawn } = require('child_process');
2
+ const path = require('path');
3
+
4
+ let serverProcess = null;
5
+ let serverPort = null;
6
+
7
+ async function waitForServer(port, maxRetries = 30, interval = 1000) {
8
+ for (let i = 0; i < maxRetries; i++) {
9
+ try {
10
+ const response = await fetch(`http://localhost:${port}/health`);
11
+ if (response.ok) {
12
+ return true;
13
+ }
14
+ } catch (e) {
15
+ // Server not ready yet
16
+ }
17
+ await new Promise(r => setTimeout(r, interval));
18
+ }
19
+ throw new Error(`Server failed to start on port ${port} after ${maxRetries} attempts`);
20
+ }
21
+
22
+ async function startJoBrowser(port = 0) {
23
+ // Use a random available port if not specified
24
+ const usePort = port || Math.floor(3100 + Math.random() * 900);
25
+
26
+ const serverPath = path.join(__dirname, '../../server-camoufox.js');
27
+
28
+ serverProcess = spawn('node', [serverPath], {
29
+ env: { ...process.env, PORT: usePort.toString(), DEBUG_RESPONSES: 'false' },
30
+ stdio: ['ignore', 'pipe', 'pipe'],
31
+ detached: false
32
+ });
33
+
34
+ serverProcess.stdout.on('data', (data) => {
35
+ if (process.env.DEBUG_SERVER) {
36
+ console.log(`[server] ${data.toString().trim()}`);
37
+ }
38
+ });
39
+
40
+ serverProcess.stderr.on('data', (data) => {
41
+ if (process.env.DEBUG_SERVER) {
42
+ console.error(`[server:err] ${data.toString().trim()}`);
43
+ }
44
+ });
45
+
46
+ serverProcess.on('error', (err) => {
47
+ console.error('Failed to start server:', err);
48
+ });
49
+
50
+ serverPort = usePort;
51
+
52
+ // Wait for server to be ready
53
+ await waitForServer(usePort);
54
+
55
+ console.log(`jo-browser server started on port ${usePort}`);
56
+ return usePort;
57
+ }
58
+
59
+ async function stopJoBrowser() {
60
+ if (serverProcess) {
61
+ return new Promise((resolve) => {
62
+ serverProcess.on('close', () => {
63
+ serverProcess = null;
64
+ serverPort = null;
65
+ resolve();
66
+ });
67
+
68
+ // Send SIGTERM for graceful shutdown
69
+ serverProcess.kill('SIGTERM');
70
+
71
+ // Force kill after 5 seconds if still running
72
+ setTimeout(() => {
73
+ if (serverProcess) {
74
+ serverProcess.kill('SIGKILL');
75
+ }
76
+ }, 5000);
77
+ });
78
+ }
79
+ }
80
+
81
+ function getServerUrl() {
82
+ if (!serverPort) throw new Error('Server not started');
83
+ return `http://localhost:${serverPort}`;
84
+ }
85
+
86
+ function getServerPort() {
87
+ return serverPort;
88
+ }
89
+
90
+ module.exports = {
91
+ startJoBrowser,
92
+ stopJoBrowser,
93
+ getServerUrl,
94
+ getServerPort
95
+ };