js_tgbrowser
Version:
Playwright helpers for connecting to TestGrid remote browsers via Selenium + CDP.
259 lines (226 loc) • 6.72 kB
JavaScript
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
};