mirror of
https://github.com/jo-inc/camofox-browser.git
synced 2026-10-02 04:14:41 +08:00
When one test's session cleanup closes a browser context, the next test's createTab can fail with 'Target page, context or browser has been closed' if the browser hasn't fully recovered. Add up to 2 retries with backoff for 500 errors matching closed/disposed/terminated patterns.
251 lines
8.0 KiB
JavaScript
251 lines
8.0 KiB
JavaScript
import crypto from 'crypto';
|
|
|
|
class BrowserClient {
|
|
constructor(baseUrl) {
|
|
this.baseUrl = baseUrl;
|
|
this.userId = crypto.randomUUID();
|
|
this.sessionKey = crypto.randomUUID();
|
|
this.listItemId = this.sessionKey; // Legacy alias
|
|
this.tabs = [];
|
|
this.timeout = process.env.CI ? 60000 : 30000;
|
|
}
|
|
|
|
async request(method, path, body = null, options = {}) {
|
|
const url = `${this.baseUrl}${path}`;
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(), options.timeout || this.timeout);
|
|
|
|
try {
|
|
const fetchOptions = {
|
|
method,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
signal: controller.signal
|
|
};
|
|
|
|
if (body) {
|
|
fetchOptions.body = JSON.stringify(body);
|
|
}
|
|
|
|
const response = await fetch(url, fetchOptions);
|
|
clearTimeout(timeoutId);
|
|
|
|
const contentType = response.headers.get('content-type');
|
|
if (contentType?.includes('application/json')) {
|
|
const data = await response.json();
|
|
if (!response.ok) {
|
|
const error = new Error(data.error || `HTTP ${response.status}`);
|
|
error.status = response.status;
|
|
error.data = data;
|
|
throw error;
|
|
}
|
|
return data;
|
|
} else if (contentType?.includes('image/')) {
|
|
return await response.arrayBuffer();
|
|
} else {
|
|
return await response.text();
|
|
}
|
|
} catch (err) {
|
|
clearTimeout(timeoutId);
|
|
if (err.name === 'AbortError') {
|
|
throw new Error(`Request timeout after ${options.timeout || this.timeout}ms: ${method} ${path}`);
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
// Health check
|
|
async health() {
|
|
return this.request('GET', '/health');
|
|
}
|
|
|
|
// Tab management
|
|
async createTab(url = null, { retries = 2 } = {}) {
|
|
const body = { userId: this.userId, sessionKey: this.sessionKey };
|
|
if (url) body.url = url;
|
|
|
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
try {
|
|
const result = await this.request('POST', '/tabs', body);
|
|
if (result.tabId) {
|
|
this.tabs.push(result.tabId);
|
|
}
|
|
return result;
|
|
} catch (err) {
|
|
const retriable = err.status === 500 && /closed|disposed|terminated/i.test(err.message);
|
|
if (!retriable || attempt === retries) throw err;
|
|
await new Promise(r => setTimeout(r, 1000 * (attempt + 1)));
|
|
}
|
|
}
|
|
}
|
|
|
|
async navigate(tabId, urlOrMacro) {
|
|
// Support both regular URLs and @macro syntax
|
|
if (urlOrMacro.startsWith('@')) {
|
|
const [macro, ...queryParts] = urlOrMacro.split(' ');
|
|
const query = queryParts.join(' ');
|
|
return this.request('POST', `/tabs/${tabId}/navigate`, { userId: this.userId, macro, query });
|
|
}
|
|
return this.request('POST', `/tabs/${tabId}/navigate`, { userId: this.userId, url: urlOrMacro });
|
|
}
|
|
|
|
async getSnapshot(tabId, options = {}) {
|
|
const params = new URLSearchParams({ userId: this.userId });
|
|
if (options.includeScreenshot) params.append('includeScreenshot', 'true');
|
|
if (options.offset) params.append('offset', String(options.offset));
|
|
return this.request('GET', `/tabs/${tabId}/snapshot?${params}`);
|
|
}
|
|
|
|
async click(tabId, options) {
|
|
return this.request('POST', `/tabs/${tabId}/click`, { userId: this.userId, ...options });
|
|
}
|
|
|
|
async type(tabId, options) {
|
|
const { pressEnter, clear, ...typeOptions } = options;
|
|
|
|
// Handle clear by selecting all first
|
|
if (clear && (options.selector || options.ref)) {
|
|
// Click to focus, then select all and type to replace
|
|
await this.click(tabId, { selector: options.selector, ref: options.ref });
|
|
await this.press(tabId, 'Control+a');
|
|
}
|
|
|
|
const result = await this.request('POST', `/tabs/${tabId}/type`, { userId: this.userId, ...typeOptions });
|
|
|
|
// Handle Enter key press after typing
|
|
if (pressEnter) {
|
|
await this.press(tabId, 'Enter');
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
async press(tabId, key) {
|
|
return this.request('POST', `/tabs/${tabId}/press`, { userId: this.userId, key });
|
|
}
|
|
|
|
async scroll(tabId, options) {
|
|
return this.request('POST', `/tabs/${tabId}/scroll`, { userId: this.userId, ...options });
|
|
}
|
|
|
|
async back(tabId) {
|
|
return this.request('POST', `/tabs/${tabId}/back`, { userId: this.userId });
|
|
}
|
|
|
|
async forward(tabId) {
|
|
return this.request('POST', `/tabs/${tabId}/forward`, { userId: this.userId });
|
|
}
|
|
|
|
async refresh(tabId) {
|
|
return this.request('POST', `/tabs/${tabId}/refresh`, { userId: this.userId });
|
|
}
|
|
|
|
async getLinks(tabId, options = {}) {
|
|
const params = new URLSearchParams({ userId: this.userId });
|
|
if (options.limit) params.append('limit', options.limit);
|
|
if (options.offset) params.append('offset', options.offset);
|
|
return this.request('GET', `/tabs/${tabId}/links?${params}`);
|
|
}
|
|
|
|
async getDownloads(tabId, options = {}) {
|
|
const params = new URLSearchParams({ userId: this.userId });
|
|
if (options.includeData) params.append('includeData', 'true');
|
|
if (options.consume) params.append('consume', 'true');
|
|
if (options.maxBytes) params.append('maxBytes', String(options.maxBytes));
|
|
return this.request('GET', `/tabs/${tabId}/downloads?${params}`);
|
|
}
|
|
|
|
async getImages(tabId, options = {}) {
|
|
const params = new URLSearchParams({ userId: this.userId });
|
|
if (options.includeData) params.append('includeData', 'true');
|
|
if (options.maxBytes) params.append('maxBytes', String(options.maxBytes));
|
|
if (options.limit) params.append('limit', String(options.limit));
|
|
return this.request('GET', `/tabs/${tabId}/images?${params}`);
|
|
}
|
|
|
|
async getStats(tabId) {
|
|
return this.request('GET', `/tabs/${tabId}/stats?userId=${this.userId}`);
|
|
}
|
|
|
|
async screenshot(tabId, fullPage = false) {
|
|
return this.request('GET', `/tabs/${tabId}/screenshot?userId=${this.userId}&fullPage=${fullPage}`);
|
|
}
|
|
|
|
async closeTab(tabId) {
|
|
const result = await this.request('DELETE', `/tabs/${tabId}`, { userId: this.userId });
|
|
this.tabs = this.tabs.filter(t => t !== tabId);
|
|
return result;
|
|
}
|
|
|
|
async closeTabGroup(sessionKey = null) {
|
|
return this.request('DELETE', `/tabs/group/${sessionKey || this.sessionKey}`, { userId: this.userId });
|
|
}
|
|
|
|
async closeSession() {
|
|
return this.request('DELETE', `/sessions/${this.userId}`);
|
|
}
|
|
|
|
// Cleanup all tabs created by this client
|
|
async cleanup() {
|
|
for (const tabId of [...this.tabs]) {
|
|
try {
|
|
await this.closeTab(tabId);
|
|
} catch (e) {
|
|
// Tab may already be closed
|
|
}
|
|
}
|
|
try {
|
|
await this.closeSession();
|
|
} catch (e) {
|
|
// Session may already be closed
|
|
}
|
|
}
|
|
|
|
// Wait for snapshot to contain specific text (polling)
|
|
async waitForSnapshotContains(tabId, text, options = {}) {
|
|
const maxWait = options.maxWait || 10000;
|
|
const interval = options.interval || 500;
|
|
const startTime = Date.now();
|
|
|
|
while (Date.now() - startTime < maxWait) {
|
|
const snapshot = await this.getSnapshot(tabId);
|
|
if (snapshot.snapshot && snapshot.snapshot.includes(text)) {
|
|
return snapshot;
|
|
}
|
|
await new Promise(r => setTimeout(r, interval));
|
|
}
|
|
|
|
throw new Error(`Timeout waiting for snapshot to contain "${text}"`);
|
|
}
|
|
|
|
// Wait for URL to match pattern
|
|
async waitForUrl(tabId, pattern, options = {}) {
|
|
const maxWait = options.maxWait || 10000;
|
|
const interval = options.interval || 500;
|
|
const startTime = Date.now();
|
|
|
|
while (Date.now() - startTime < maxWait) {
|
|
const snapshot = await this.getSnapshot(tabId);
|
|
const url = snapshot.url;
|
|
|
|
if (typeof pattern === 'string' && url.includes(pattern)) {
|
|
return snapshot;
|
|
} else if (pattern instanceof RegExp && pattern.test(url)) {
|
|
return snapshot;
|
|
}
|
|
|
|
await new Promise(r => setTimeout(r, interval));
|
|
}
|
|
|
|
throw new Error(`Timeout waiting for URL to match ${pattern}`);
|
|
}
|
|
}
|
|
|
|
function createClient(baseUrl) {
|
|
return new BrowserClient(baseUrl);
|
|
}
|
|
|
|
export {
|
|
BrowserClient,
|
|
createClient
|
|
};
|