From 75efeb1e08f8fe83d1880520688bdb17bdccbaae Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Tue, 9 Nov 2021 14:41:13 -0800 Subject: [PATCH] fix: resolve ip using grid/api/testsession endpoint (#10196) For Selenium 4, we use se:cdp ws proxy, pointing it to the hub url. For Selenium 3, we use grid api to try and get the target node ip. --- .../src/server/chromium/chromium.ts | 86 +++++++++++++------ packages/playwright-core/src/utils/utils.ts | 6 +- tests/browsertype-launch-selenium.spec.ts | 75 ++++++++++++++-- 3 files changed, 134 insertions(+), 33 deletions(-) diff --git a/packages/playwright-core/src/server/chromium/chromium.ts b/packages/playwright-core/src/server/chromium/chromium.ts index c0d77dcf50..d200a1e1e1 100644 --- a/packages/playwright-core/src/server/chromium/chromium.ts +++ b/packages/playwright-core/src/server/chromium/chromium.ts @@ -27,7 +27,7 @@ import { ConnectionTransport, ProtocolRequest, WebSocketTransport } from '../tra import { CRDevTools } from './crDevTools'; import { Browser, BrowserOptions, BrowserProcess, PlaywrightOptions } from '../browser'; import * as types from '../types'; -import { debugMode, fetchData, headersArrayToObject, removeFolders, streamToString } from '../../utils/utils'; +import { debugMode, fetchData, headersArrayToObject, HTTPRequestParams, removeFolders, streamToString } from '../../utils/utils'; import { RecentLogsCollector } from '../../utils/debugLogger'; import { Progress, ProgressController } from '../progress'; import { TimeoutSettings } from '../../utils/timeoutSettings'; @@ -65,7 +65,7 @@ export class Chromium extends BrowserType { const artifactsDir = await fs.promises.mkdtemp(ARTIFACTS_FOLDER); - const wsEndpoint = await urlToWSEndpoint(endpointURL); + const wsEndpoint = await urlToWSEndpoint(progress, endpointURL); progress.throwIfAborted(); const chromeTransport = await WebSocketTransport.connect(progress, wsEndpoint, headersMap); @@ -153,7 +153,7 @@ export class Chromium extends BrowserType { args.push('--remote-debugging-port=0'); const desiredCapabilities = { 'browserName': 'chrome', 'goog:chromeOptions': { args } }; - progress.log(` ${hubUrl}`); + progress.log(` connecting to ${hubUrl}`); const response = await fetchData({ url: hubUrl + 'session', method: 'POST', @@ -162,41 +162,59 @@ export class Chromium extends BrowserType { capabilities: { alwaysMatch: desiredCapabilities } }), timeout: progress.timeUntilDeadline(), - }, async response => { - const body = await streamToString(response); - let message = ''; - try { - const json = JSON.parse(body); - message = json.value.localizedMessage || json.value.message; - } catch (e) { - } - return new Error(`Error connecting to Selenium at ${hubUrl}: ${message}`); - }); + }, seleniumErrorHandler); const value = JSON.parse(response).value; const sessionId = value.sessionId; - progress.log(` sessionId=${sessionId}`); + progress.log(` connected to sessionId=${sessionId}`); const disconnectFromSelenium = async () => { - progress.log(` sessionId=${sessionId}`); + progress.log(` disconnecting from sessionId=${sessionId}`); await fetchData({ url: hubUrl + 'session/' + sessionId, method: 'DELETE', }).catch(error => progress.log(`: ${error}`)); - progress.log(` sessionId=${sessionId}`); + progress.log(` disconnected from sessionId=${sessionId}`); gracefullyCloseSet.delete(disconnectFromSelenium); }; gracefullyCloseSet.add(disconnectFromSelenium); try { const capabilities = value.capabilities; - const maybeChromeOptions = capabilities['goog:chromeOptions']; - const chromeOptions = maybeChromeOptions && typeof maybeChromeOptions === 'object' ? maybeChromeOptions : undefined; - const debuggerAddress = chromeOptions && typeof chromeOptions.debuggerAddress === 'string' ? chromeOptions.debuggerAddress : undefined; - const chromeOptionsURL = typeof maybeChromeOptions === 'string' ? maybeChromeOptions : undefined; - let endpointURL = capabilities['se:cdp'] || debuggerAddress || chromeOptionsURL; - if (!['ws://', 'wss://', 'http://', 'https://'].some(protocol => endpointURL.startsWith(protocol))) - endpointURL = 'http://' + endpointURL; - return this._connectOverCDPInternal(progress, endpointURL, { slowMo: options.slowMo }, disconnectFromSelenium); + let endpointURL: URL; + + if (capabilities['se:cdp']) { + // Selenium 4 - use built-in CDP websocket proxy. + const endpointURLString = addProtocol(capabilities['se:cdp']); + endpointURL = new URL(endpointURLString); + endpointURL.hostname = new URL(hubUrl).hostname; + progress.log(` retrieved endpoint ${endpointURL.toString()} for sessionId=${sessionId}`); + } else { + // Selenium 3 - resolve target node IP to use instead of localhost ws url. + const maybeChromeOptions = capabilities['goog:chromeOptions']; + const chromeOptions = maybeChromeOptions && typeof maybeChromeOptions === 'object' ? maybeChromeOptions : undefined; + const debuggerAddress = chromeOptions && typeof chromeOptions.debuggerAddress === 'string' ? chromeOptions.debuggerAddress : undefined; + const chromeOptionsURL = typeof maybeChromeOptions === 'string' ? maybeChromeOptions : undefined; + const endpointURLString = addProtocol(debuggerAddress || chromeOptionsURL); + progress.log(` retrieved endpoint ${endpointURLString} for sessionId=${sessionId}`); + endpointURL = new URL(endpointURLString); + if (endpointURL.hostname === 'localhost' || endpointURL.hostname === '127.0.0.1') { + const sessionInfoUrl = new URL(hubUrl).origin + '/grid/api/testsession?session=' + sessionId; + try { + const sessionResponse = await fetchData({ + url: sessionInfoUrl, + method: 'GET', + timeout: progress.timeUntilDeadline(), + }, seleniumErrorHandler); + const proxyId = JSON.parse(sessionResponse).proxyId; + endpointURL.hostname = new URL(proxyId).hostname; + progress.log(` resolved endpoint ip ${endpointURL.toString()} for sessionId=${sessionId}`); + } catch (e) { + progress.log(` unable to resolve endpoint ip for sessionId=${sessionId}, running in standalone?`); + } + } + } + + return await this._connectOverCDPInternal(progress, endpointURL.toString(), { slowMo: options.slowMo }, disconnectFromSelenium); } catch (e) { await disconnectFromSelenium(); throw e; @@ -296,9 +314,10 @@ const DEFAULT_ARGS = [ '--no-service-autorun', ]; -async function urlToWSEndpoint(endpointURL: string) { +async function urlToWSEndpoint(progress: Progress, endpointURL: string) { if (endpointURL.startsWith('ws')) return endpointURL; + progress.log(` retrieving websocket url from ${endpointURL}`); const httpURL = endpointURL.endsWith('/') ? `${endpointURL}json/version/` : `${endpointURL}/json/version/`; const request = endpointURL.startsWith('https') ? https : http; const json = await new Promise((resolve, reject) => { @@ -314,3 +333,20 @@ async function urlToWSEndpoint(endpointURL: string) { }); return JSON.parse(json).webSocketDebuggerUrl; } + +async function seleniumErrorHandler(params: HTTPRequestParams, response: http.IncomingMessage) { + const body = await streamToString(response); + let message = body; + try { + const json = JSON.parse(body); + message = json.value.localizedMessage || json.value.message; + } catch (e) { + } + return new Error(`Error connecting to Selenium at ${params.url}: ${message}`); +} + +function addProtocol(url: string) { + if (!['ws://', 'wss://', 'http://', 'https://'].some(protocol => url.startsWith(protocol))) + return 'http://' + url; + return url; +} diff --git a/packages/playwright-core/src/utils/utils.ts b/packages/playwright-core/src/utils/utils.ts index a824750113..2e36790f4b 100644 --- a/packages/playwright-core/src/utils/utils.ts +++ b/packages/playwright-core/src/utils/utils.ts @@ -40,7 +40,7 @@ const ProxyAgent = require('https-proxy-agent'); export const existsAsync = (path: string): Promise => new Promise(resolve => fs.stat(path, err => resolve(!err))); -type HTTPRequestParams = { +export type HTTPRequestParams = { url: string, method?: string, headers?: http.OutgoingHttpHeaders, @@ -97,11 +97,11 @@ function httpRequest(params: HTTPRequestParams, onResponse: (r: http.IncomingMes request.end(params.data); } -export function fetchData(params: HTTPRequestParams, onError?: (response: http.IncomingMessage) => Promise): Promise { +export function fetchData(params: HTTPRequestParams, onError?: (params: HTTPRequestParams, response: http.IncomingMessage) => Promise): Promise { return new Promise((resolve, reject) => { httpRequest(params, async response => { if (response.statusCode !== 200) { - const error = onError ? await onError(response) : new Error(`fetch failed: server returned code ${response.statusCode}. URL: ${params.url}`); + const error = onError ? await onError(params, response) : new Error(`fetch failed: server returned code ${response.statusCode}. URL: ${params.url}`); reject(error); return; } diff --git a/tests/browsertype-launch-selenium.spec.ts b/tests/browsertype-launch-selenium.spec.ts index d518d5b2f3..f7a405d8a7 100644 --- a/tests/browsertype-launch-selenium.spec.ts +++ b/tests/browsertype-launch-selenium.spec.ts @@ -22,13 +22,13 @@ import { start } from '../packages/playwright-core/lib/outofprocess'; const chromeDriver = require('chromedriver').path; const brokenDriver = path.join(__dirname, 'assets', 'selenium-grid', 'broken-selenium-driver.js'); -const seleniumConfigStandalone = path.join(__dirname, 'assets', 'selenium-grid', 'selenium-config-standalone.json'); const standalone_3_141_59 = path.join(__dirname, 'assets', 'selenium-grid', 'selenium-server-standalone-3.141.59.jar'); const selenium_4_0_0_rc1 = path.join(__dirname, 'assets', 'selenium-grid', 'selenium-server-4.0.0-rc-1.jar'); function writeSeleniumConfig(testInfo: TestInfo, port: number) { - const content = fs.readFileSync(seleniumConfigStandalone, 'utf8').replace(/4444/g, String(port)); - const file = testInfo.outputPath('selenium-config.json'); + const template = path.join(__dirname, 'assets', 'selenium-grid', `selenium-config-standalone.json`); + const content = fs.readFileSync(template, 'utf8').replace(/4444/g, String(port)); + const file = testInfo.outputPath(`selenium-config-standalone.json`); fs.writeFileSync(file, content, 'utf8'); return file; } @@ -60,6 +60,39 @@ test('selenium grid 3.141.59 standalone chromium', async ({ browserName, childPr await grid.waitForOutput('Removing session'); }); +test('selenium grid 3.141.59 hub + node chromium', async ({ browserName, childProcess, waitForPort, browserType }, testInfo) => { + test.skip(browserName !== 'chromium'); + + const port = testInfo.workerIndex + 15123; + const hub = childProcess({ + command: ['java', '-jar', standalone_3_141_59, '-role', 'hub', '-port', String(port)], + cwd: __dirname, + }); + await waitForPort(port); + + const node = childProcess({ + command: ['java', `-Dwebdriver.chrome.driver=${chromeDriver}`, '-jar', standalone_3_141_59, '-role', 'node', '-host', '127.0.0.1', '-hub', `http://localhost:${port}/grid/register`], + cwd: __dirname, + }); + await Promise.all([ + node.waitForOutput('The node is registered to the hub and ready to use'), + hub.waitForOutput('Registered a node'), + ]); + + const __testHookSeleniumRemoteURL = `http://localhost:${port}/wd/hub`; + const browser = await browserType.launch({ __testHookSeleniumRemoteURL } as any); + const page = await browser.newPage(); + await page.setContent('Hello world
Get Started
'); + await page.click('text=Get Started'); + await expect(page).toHaveTitle('Hello world'); + await browser.close(); + + expect(hub.output).toContain('Got a request to create a new session'); + expect(node.output).toContain('Starting ChromeDriver'); + expect(node.output).toContain('Started new session'); + await node.waitForOutput('Removing session'); +}); + test('selenium grid 4.0.0-rc-1 standalone chromium', async ({ browserName, childProcess, waitForPort, browserType }, testInfo) => { test.skip(browserName !== 'chromium'); @@ -83,6 +116,38 @@ test('selenium grid 4.0.0-rc-1 standalone chromium', async ({ browserName, child await grid.waitForOutput('Deleted session'); }); +test('selenium grid 4.0.0-rc-1 hub + node chromium', async ({ browserName, childProcess, waitForPort, browserType }, testInfo) => { + test.skip(browserName !== 'chromium'); + + const port = testInfo.workerIndex + 15123; + const hub = childProcess({ + command: ['java', '-jar', selenium_4_0_0_rc1, 'hub', '--port', String(port)], + cwd: __dirname, + }); + await waitForPort(port); + const __testHookSeleniumRemoteURL = `http://localhost:${port}/wd/hub`; + + const node = childProcess({ + command: ['java', `-Dwebdriver.chrome.driver=${chromeDriver}`, '-jar', selenium_4_0_0_rc1, 'node', '--grid-url', `http://localhost:${port}`, '--port', String(port + 1)], + cwd: __dirname, + }); + await Promise.all([ + node.waitForOutput('Node has been added'), + hub.waitForOutput('from DOWN to UP'), + ]); + + const browser = await browserType.launch({ __testHookSeleniumRemoteURL } as any); + const page = await browser.newPage(); + await page.setContent('Hello world
Get Started
'); + await page.click('text=Get Started'); + await expect(page).toHaveTitle('Hello world'); + await browser.close(); + + expect(hub.output).toContain('Session request received by the distributor'); + expect(node.output).toContain('Starting ChromeDriver'); + await hub.waitForOutput('Deleted session'); +}); + test('selenium grid 4.0.0-rc-1 standalone chromium broken driver', async ({ browserName, childProcess, waitForPort, browserType }, testInfo) => { test.skip(browserName !== 'chromium'); @@ -95,7 +160,7 @@ test('selenium grid 4.0.0-rc-1 standalone chromium broken driver', async ({ brow const __testHookSeleniumRemoteURL = `http://localhost:${port}/wd/hub`; const error = await browserType.launch({ __testHookSeleniumRemoteURL } as any).catch(e => e); - expect(error.message).toContain(`Error connecting to Selenium at http://localhost:${port}/wd/hub/: Could not start a new session`); + expect(error.message).toContain(`Error connecting to Selenium at http://localhost:${port}/wd/hub/session: Could not start a new session`); expect(grid.output).not.toContain('Starting ChromeDriver'); }); @@ -108,7 +173,7 @@ test('selenium grid 3.141.59 standalone non-chromium', async ({ browserName, bro expect(error.message).toContain('Connecting to SELENIUM_REMOTE_URL is only supported by Chromium'); }); -test('selenium grid 3.141.59 standalone chromium through driver', async ({ browserName, childProcess, waitForPort }, testInfo) => { +test('selenium grid 3.141.59 standalone chromium through run-driver', async ({ browserName, childProcess, waitForPort }, testInfo) => { test.skip(browserName !== 'chromium'); const port = testInfo.workerIndex + 15123;