UNPKG

js_tgbrowser

Version:

Playwright helpers for connecting to TestGrid remote browsers via Selenium + CDP.

259 lines (226 loc) 6.72 kB
const { chromium } = require('@playwright/test'); const http = require('http'); const https = require('https'); function parseJsonEnv(name) { const raw = process.env[name]; if (!raw) { return undefined; } try { return JSON.parse(raw); } catch (error) { throw new Error(`Failed to parse ${name}: ${error.message}`); } } function parseStringEnv(name) { const raw = process.env[name]; if (!raw || !raw.trim()) { return undefined; } return raw.trim(); } function normaliseBaseUrl(remoteUrl) { if (!remoteUrl.endsWith('/')) { return `${remoteUrl}/`; } return remoteUrl; } function buildHeaders(baseHeaders) { const headers = { 'Content-Type': 'application/json; charset=utf-8' }; if (!baseHeaders) { return headers; } for (const [key, value] of Object.entries(baseHeaders)) { headers[key] = value; } return headers; } function httpRequest(urlString, { method = 'GET', headers = {}, body, timeout = 90000 }) { return new Promise((resolve, reject) => { const url = new URL(urlString); const transport = url.protocol === 'https:' ? https : http; const requestHeaders = { ...headers }; const headerLookup = Object.keys(requestHeaders).reduce((acc, key) => { acc[key.toLowerCase()] = true; return acc; }, {}); let path = url.pathname || '/'; if (!path.startsWith('/')) { path = `/${path}`; } if (url.search) { path += url.search; } const options = { method, hostname: url.hostname, port: url.port || (url.protocol === 'https:' ? 443 : 80), path, headers: requestHeaders }; if (url.username || url.password) { options.auth = `${url.username}:${url.password}`; } let resolved = false; const chunks = []; const onError = error => { if (!resolved) { resolved = true; reject(error); } }; const req = transport.request(options, res => { res.on('data', chunk => chunks.push(chunk)); res.on('end', () => { if (resolved) return; resolved = true; const bodyBuffer = Buffer.concat(chunks); resolve({ status: res.statusCode || 0, headers: res.headers, body: bodyBuffer.toString('utf-8') }); }); }); req.on('error', onError); req.setTimeout(timeout, () => { req.destroy(new Error(`Request to ${urlString} timed out after ${timeout}ms`)); }); if (body) { if (!headerLookup['content-length']) { req.setHeader('Content-Length', Buffer.byteLength(body)); } req.write(body); } req.end(); }); } function buildDesiredCapabilities(explicitCaps) { const base = { browserName: 'chrome' }; if (!explicitCaps) { return base; } return { ...base, ...explicitCaps }; } function resolveCdpEndpoint({ remoteUrl, sessionId, capabilities, proxyPath, explicitCdpUrl }) { const hubUrl = new URL(remoteUrl); const wsProtocol = hubUrl.protocol === 'https:' ? 'wss:' : 'ws:'; if (explicitCdpUrl) { return explicitCdpUrl.replace('{sessionId}', sessionId); } if (proxyPath) { const trimmed = proxyPath.replace(/^\/+/, '').replace(/\/+$/, ''); return `${wsProtocol}//${hubUrl.host}/${trimmed}/session/${sessionId}/se/cdp`; } if (capabilities && capabilities['tg:cdpPath']) { const trimmed = `${capabilities['tg:cdpPath']}`.replace(/^\/+/, '').replace(/\/+$/, ''); return `${wsProtocol}//${hubUrl.host}/${trimmed}/session/${sessionId}/se/cdp`; } const seleniumEndpoint = capabilities && capabilities['se:cdp']; if (!seleniumEndpoint) { throw new Error('Unable to determine CDP endpoint from Selenium response. Provide TESTGRID_CDP_PROXY_PATH or TESTGRID_CDP_URL.'); } const parsed = new URL(seleniumEndpoint); if (parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1') { parsed.hostname = hubUrl.hostname; } if (!parsed.port && hubUrl.port) { parsed.port = hubUrl.port; } parsed.protocol = wsProtocol; return parsed.toString(); } async function createRemoteBrowser() { const remoteUrl = process.env.SELENIUM_REMOTE_URL; if (!remoteUrl) { throw new Error('SELENIUM_REMOTE_URL must be set to use the TestGrid remote helper.'); } const capabilitiesEnv = parseJsonEnv('SELENIUM_REMOTE_CAPABILITIES'); const headersEnv = parseJsonEnv('SELENIUM_REMOTE_HEADERS'); const proxyPath = process.env.TESTGRID_CDP_PROXY_PATH; const explicitCdpUrl = process.env.TESTGRID_CDP_URL; const hostHeaderOverride = parseStringEnv('TESTGRID_HOST_HEADER'); const baseUrl = normaliseBaseUrl(remoteUrl); const headers = buildHeaders(headersEnv); if (hostHeaderOverride) { headers['Host'] = hostHeaderOverride; } const desiredCapabilities = buildDesiredCapabilities(capabilitiesEnv); const requestBody = JSON.stringify({ capabilities: { alwaysMatch: desiredCapabilities } }); const createResponse = await httpRequest(`${baseUrl}session`, { method: 'POST', headers, body: requestBody }); if (createResponse.status < 200 || createResponse.status >= 300) { throw new Error(`Failed to create Selenium session: [${createResponse.status}] ${createResponse.body}`); } let payload; try { payload = JSON.parse(createResponse.body || '{}'); } catch (error) { throw new Error(`Unable to parse Selenium session response: ${error.message}`); } if (!payload.value) { throw new Error(`Unexpected Selenium session payload: ${JSON.stringify(payload)}`); } if (payload.value.error) { throw new Error(`Selenium session error: ${payload.value.error} - ${payload.value.message}`); } const sessionId = payload.value.sessionId; const returnedCaps = payload.value.capabilities || {}; if (!sessionId) { throw new Error(`Missing sessionId in Selenium response: ${JSON.stringify(payload)}`); } const endpoint = resolveCdpEndpoint({ remoteUrl, sessionId, capabilities: returnedCaps, proxyPath, explicitCdpUrl }); const browser = await chromium.connectOverCDP(endpoint); async function dispose() { try { if (browser && browser.isConnected()) { await browser.close(); } } catch (error) { console.warn('Failed to close browser cleanly:', error); } try { await httpRequest(`${baseUrl}session/${sessionId}`, { method: 'DELETE', headers }); } catch (error) { console.warn('Failed to release Selenium session:', error); } } return { browser, sessionId, capabilities: returnedCaps, dispose }; } module.exports = { createRemoteBrowser };