@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.
- package/.env.bak +4 -0
- package/.github/workflows/deploy.yml +21 -0
- package/AGENTS.md +153 -0
- package/Dockerfile.camoufox +59 -0
- package/LICENSE +21 -0
- package/README.md +234 -0
- package/SKILL.md +165 -0
- package/experimental/chromium/Dockerfile +35 -0
- package/experimental/chromium/README.md +47 -0
- package/experimental/chromium/run.sh +24 -0
- package/experimental/chromium/server.js +812 -0
- package/fly.toml +29 -0
- package/jest.config.js +41 -0
- package/lib/macros.js +30 -0
- package/openclaw.plugin.json +31 -0
- package/package.json +30 -0
- package/plugin.ts +312 -0
- package/run-camoufox.sh +37 -0
- package/server-camoufox.js +946 -0
- package/tests/e2e/concurrency.test.js +103 -0
- package/tests/e2e/formSubmission.test.js +129 -0
- package/tests/e2e/macroNavigation.test.js +92 -0
- package/tests/e2e/navigation.test.js +128 -0
- package/tests/e2e/scroll.test.js +81 -0
- package/tests/e2e/snapshotLinks.test.js +141 -0
- package/tests/e2e/tabLifecycle.test.js +149 -0
- package/tests/e2e/typingEnter.test.js +147 -0
- package/tests/helpers/client.js +222 -0
- package/tests/helpers/startJoBrowser.js +95 -0
- package/tests/helpers/testSite.js +238 -0
- package/tests/live/googleSearch.test.js +93 -0
- package/tests/live/macroExpansion.test.js +132 -0
- package/tests/unit/macros.test.js +123 -0
|
@@ -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
|
+
};
|