diff --git a/packages/playwright-core/bin/container_landing.html b/packages/playwright-core/bin/container_landing.html new file mode 100644 index 0000000000..e1e41b0e18 --- /dev/null +++ b/packages/playwright-core/bin/container_landing.html @@ -0,0 +1,35 @@ + + + +

Playwright Container

+ View Screen + diff --git a/packages/playwright-core/bin/container_novnc_proxy.js b/packages/playwright-core/bin/container_novnc_proxy.js new file mode 100644 index 0000000000..ab1fa1504d --- /dev/null +++ b/packages/playwright-core/bin/container_novnc_proxy.js @@ -0,0 +1,69 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const http = require('http'); +const fs = require('fs'); +const path = require('path'); +const { debug, program } = require('../lib/utilsBundle'); +const { ProxyServer } = require('../lib/third_party/http_proxy'); + +const debugLog = debug('pw:proxy'); + +program + .command('start') + .description('reverse proxy for novnc and playwright server') + .option('--port ', 'port number') + .option('--server-endpoint ', 'Playwright Server endpoint') + .option('--novnc-endpoint ', 'novnc server endpoint') + .option('--novnc-ws-path ', 'novnc websocket path') + .action(async function(options) { + launchReverseProxy(options.port, options.serverEndpoint, options.novncEndpoint, options.novncWsPath); + }); + +program.parse(process.argv); + +async function launchReverseProxy(port, serverEndpoint, novncEndpoint, novncWSPath) { + const vncProxy = new ProxyServer(novncEndpoint, debugLog); + const serverProxy = new ProxyServer(serverEndpoint, debugLog); + + const httpServer = http.createServer((request, response) => { + if (request.url === '/' && request.method === 'GET') { + response.writeHead(200, { + 'content-type': 'text/html', + }).end(fs.readFileSync(path.join(__dirname, 'container_landing.html'), 'utf-8')); + } else if ((request.url === '/screen' || request.url === '/screen/') && request.method === 'GET') { + response.writeHead(307, { + Location: `/screen/?resize=scale&autoconnect=1&path=${novncWSPath}`, + }).end(); + } else if (request.url?.startsWith('/screen')) { + request.url = request.url.substring('/screen'.length); + vncProxy.web(request, response); + } else { + serverProxy.web(request, response); + } + }); + httpServer.on('error', error => debugLog(error)); + httpServer.on('upgrade', (request, socket, head) => { + if (request.url === '/' + novncWSPath) + vncProxy.ws(request, socket, head); + else + serverProxy.ws(request, socket, head); + }); + httpServer.listen(port, () => { + console.log('Playwright container listening on', port); + }); +} + diff --git a/packages/playwright-core/bin/container_run_server.sh b/packages/playwright-core/bin/container_run_server.sh index 0813591741..f6f4a2afe5 100755 --- a/packages/playwright-core/bin/container_run_server.sh +++ b/packages/playwright-core/bin/container_run_server.sh @@ -1,5 +1,9 @@ #!/bin/bash set -e + +trap "cd $(pwd -P)" EXIT +cd "$(dirname "$0")" + SCREEN_WIDTH=1360 SCREEN_HEIGHT=1020 SCREEN_DEPTH=24 @@ -19,20 +23,22 @@ for i in $(seq 1 500); do sleep 0.2 done +# Launch x11 nohup x11vnc -noprimary -nosetprimary -forever -shared -rfbport 5900 -rfbportv6 5900 -display "$DISPLAY" >/dev/null 2>&1 & +# Launch novnc nohup /opt/bin/noVNC/utils/novnc_proxy --listen 7900 --vnc localhost:5900 >/dev/null 2>&1 & +# Launch reverse proxy +NOVNC_UUID=$(cat /proc/sys/kernel/random/uuid) +node ./container_novnc_proxy.js start --server-endpoint="http://127.0.0.1:5200" --novnc-endpoint="http://127.0.0.1:7900" --novnc-ws-path="${NOVNC_UUID}" --port 5400 & cd /ms-playwright-agent -NOVNC_UUID=$(cat /proc/sys/kernel/random/uuid) -echo "novnc is listening on http://127.0.0.1:7900?path=$NOVNC_UUID&resize=scale&autoconnect=1" - PW_UUID=$(cat /proc/sys/kernel/random/uuid) # Make sure to re-start playwright server if something goes wrong. # The approach taken from: https://stackoverflow.com/a/697064/314883 -until npx playwright run-server --port=5400 --path=/$PW_UUID --proxy-mode=tether; do +until npx playwright run-server --port=5200 --path=/$PW_UUID --proxy-mode=tether; do echo "Server crashed with exit code $?. Respawning.." >&2 sleep 1 done diff --git a/packages/playwright-core/src/containers/docker.ts b/packages/playwright-core/src/containers/docker.ts index a65fcfd795..de5ffdd0b6 100644 --- a/packages/playwright-core/src/containers/docker.ts +++ b/packages/playwright-core/src/containers/docker.ts @@ -42,13 +42,8 @@ async function startPlaywrightContainer(port: number) { const info = await ensurePlaywrightContainerOrDie(port); const deltaMs = (Date.now() - time); console.log('Done in ' + (deltaMs / 1000).toFixed(1) + 's'); - await tetherHostNetwork(info.wsEndpoint); - - console.log([ - `- Endpoint: ${info.httpEndpoint}`, - `- View screen:`, - ` ${info.vncSession}`, - ].join('\n')); + await tetherHostNetwork(info.httpEndpoint); + console.log('Endpoint:', info.httpEndpoint); } async function stopAllPlaywrightContainers() { @@ -133,8 +128,6 @@ async function buildPlaywrightImage() { interface ContainerInfo { httpEndpoint: string; - wsEndpoint: string; - vncSession: string; } async function printDockerStatus() { @@ -145,8 +138,7 @@ async function printDockerStatus() { dockerEngineRunning: isDockerEngine, imageName: VRT_IMAGE_NAME, imageIsPulled, - containerWSEndpoint: info?.wsEndpoint ?? '', - containerVNCEndpoint: info?.vncSession ?? '', + containerEndpoint: info?.httpEndpoint ?? '', }, null, 2)); } @@ -170,18 +162,13 @@ export async function containerInfo(): Promise { }; const WS_LINE_PREFIX = 'Listening on ws://'; + const REVERSE_PROXY_LINE_PREFIX = 'Playwright container listening on'; const webSocketLine = logLines.find(line => line.startsWith(WS_LINE_PREFIX)); - const NOVNC_LINE_PREFIX = 'novnc is listening on '; - const novncLine = logLines.find(line => line.startsWith(NOVNC_LINE_PREFIX)); - if (!novncLine || !webSocketLine) + const reverseProxyLine = logLines.find(line => line.startsWith(REVERSE_PROXY_LINE_PREFIX)); + if (!webSocketLine || !reverseProxyLine) return undefined; - const wsEndpoint = containerUrlToHostUrl('ws://' + webSocketLine.substring(WS_LINE_PREFIX.length)); - const vncSession = containerUrlToHostUrl(novncLine.substring(NOVNC_LINE_PREFIX.length)); - if (!wsEndpoint || !vncSession) - return undefined; - const wsUrl = new URL(wsEndpoint); - const httpEndpoint = 'http://' + wsUrl.host; - return { wsEndpoint, vncSession, httpEndpoint }; + const httpEndpoint = containerUrlToHostUrl('http://127.0.0.1:' + reverseProxyLine.substring(REVERSE_PROXY_LINE_PREFIX.length).trim()); + return httpEndpoint ? { httpEndpoint } : undefined; } export async function ensurePlaywrightContainerOrDie(port: number): Promise { @@ -249,7 +236,6 @@ export async function ensurePlaywrightContainerOrDie(port: number): Promise {}) { + this._target = URL.parse(target); + this._log = log; + if (this._target.path !== '/') + throw new Error('ERROR: target must have no path'); + this._agent = this._target.protocol === 'https:' ? https : http; + } + + web(req, res) { + if ((req.method === 'DELETE' || req.method === 'OPTIONS') && !req.headers['content-length']) { + req.headers['content-length'] = '0'; + delete req.headers['transfer-encoding']; + } + + // Request initalization + const options = { + protocol: this._target.protocol, + hostname: this._target.hostname, + port: this._target.port, + path: req.url, + method: req.method, + headers: req.headers, + }; + if (typeof options.headers.connection !== 'string' || !upgradeHeader.test(options.headers.connection)) + options.headers.connection = 'close'; + const proxyReq = this._agent.request(options); + + req.on('aborted', () => proxyReq.abort()); + + const errorHandler = error => { + this._log(error); + if (req.socket.destroyed && err.code === 'ECONNRESET') + return proxyReq.abort(); + } + req.on('error', errorHandler); + proxyReq.on('error', errorHandler); + + req.pipe(proxyReq); + + proxyReq.on('response', proxyRes => { + if (!res.headersSent) { + if (req.httpVersion !== '2.0' && !proxyRes.headers.connection) + proxyRes.headers.connection = req.headers.connection || 'keep-alive'; + for (const [key, value] of Object.entries(proxyRes.headers)) { + if (value !== undefined) + res.setHeader(String(key).trim(), value); + } + + res.statusCode = proxyRes.statusCode; + if (proxyRes.statusMessage) + res.statusMessage = proxyRes.statusMessage; + } + if (!res.finished) + proxyRes.pipe(res); + }); + } + + ws(req, socket, head) { + if (req.method !== 'GET' || !req.headers.upgrade || req.headers.upgrade.toLowerCase() !== 'websocket') { + socket.destroy(); + return; + } + + socket.setTimeout(0); + socket.setNoDelay(true); + socket.setKeepAlive(true, 0); + + if (head && head.length) + socket.unshift(head); + + const proxyReq = this._agent.request({ + protocol: this._target.protocol, + hostname: this._target.hostname, + port: this._target.port, + path: req.url, + method: req.method, + headers: req.headers, + }); + + // Error Handler + const errorHandler = err => { + this._log(err); + socket.end(); + } + socket.on('error', errorHandler); + proxyReq.on('error', errorHandler); + proxyReq.on('response', function (res) { + // if upgrade event isn't going to happen, close the socket + if (!res.upgrade) { + socket.write(createHTTPHeader('HTTP/' + res.httpVersion + ' ' + res.statusCode + ' ' + res.statusMessage, res.headers)); + res.pipe(socket); + } + }); + proxyReq.on('upgrade', function(proxyRes, proxySocket, proxyHead) { + proxySocket.on('error', errorHandler); + + // The pipe below will end proxySocket if socket closes cleanly, but not + // if it errors (eg, vanishes from the net and starts returning + // EHOSTUNREACH). We need to do that explicitly. + socket.on('error', () => proxySocket.end()); + + proxySocket.setTimeout(0); + proxySocket.setNoDelay(true); + proxySocket.setKeepAlive(true, 0); + + if (proxyHead && proxyHead.length) + proxySocket.unshift(proxyHead); + + // + // Remark: Handle writing the headers to the socket when switching protocols + // Also handles when a header is an array + // + socket.write(createHTTPHeader('HTTP/1.1 101 Switching Protocols', proxyRes.headers)); + proxySocket.pipe(socket).pipe(proxySocket); + }); + return proxyReq.end(); + } +} + +function createHTTPHeader(line, headers) { + const lines = [line]; + for (const [key, arrayOrValue] of Object.entries(headers)) { + for (const value of [arrayOrValue].flat()) + lines.push(key + ': ' + value); + } + return lines.join('\r\n') + '\r\n\r\n'; +} + +module.exports = { ProxyServer }; diff --git a/packages/playwright-test/src/plugins/dockerPlugin.ts b/packages/playwright-test/src/plugins/dockerPlugin.ts index 297dbda76d..32d6e7fc15 100644 --- a/packages/playwright-test/src/plugins/dockerPlugin.ts +++ b/packages/playwright-test/src/plugins/dockerPlugin.ts @@ -33,9 +33,8 @@ export const dockerPlugin: TestRunnerPlugin = { const info = await containerInfo(); if (!info) throw new Error('ERROR: please launch docker container separately!'); - println(colors.dim(`View screen: ${info.vncSession}`)); println(''); - process.env.PW_TEST_CONNECT_WS_ENDPOINT = info.wsEndpoint; + process.env.PW_TEST_CONNECT_WS_ENDPOINT = info.httpEndpoint; process.env.PW_TEST_CONNECT_HEADERS = JSON.stringify({ 'x-playwright-proxy': '*', }); diff --git a/tests/installation/docker-integration.spec.ts b/tests/installation/docker-integration.spec.ts index 60165b4a7b..345fa0ba5b 100755 --- a/tests/installation/docker-integration.spec.ts +++ b/tests/installation/docker-integration.spec.ts @@ -48,7 +48,7 @@ test.describe('installed image', () => { shell: true, cwd: path.join(__dirname, '..', '..'), }); - await dockerProcess.waitForOutput('- Endpoint:'); + await dockerProcess.waitForOutput('Endpoint:'); }); test.afterAll(async ({ exec }) => {