diff --git a/tools/e2e-commons/bin/cloudflaretunnel.js b/tools/e2e-commons/bin/cloudflaretunnel.js deleted file mode 100644 index a838a9925d1aa..0000000000000 --- a/tools/e2e-commons/bin/cloudflaretunnel.js +++ /dev/null @@ -1,211 +0,0 @@ -#!/usr/bin/env node - -import childProcess from 'child_process'; -import fs from 'fs'; -import config from 'config'; -import yargs from 'yargs'; -import { hideBin } from 'yargs/helpers'; - -const tunnelConfig = config.get( 'tunnel' ); - -fs.mkdirSync( config.get( 'dirs.temp' ), { recursive: true } ); - -/** - * Log a message with cloudflared manager prefix - * @param {...*} args - Arguments to log - */ -function log( ...args ) { - console.log( '[cloudflared manager]', ...args ); -} - -// eslint-disable-next-line @typescript-eslint/no-unused-expressions -yargs( hideBin( process.argv ) ) - .usage( 'Usage: $0 ' ) - .demandCommand( 1, 1 ) - .command( - 'on [logfile]', - 'Opens a cloudflare tunnel', - yarg => { - yarg.positional( 'logfile', { - describe: 'File to write tunnel logs to', - type: 'string', - } ); - }, - tunnelOn - ) - .command( 'child', false, () => {}, tunnelChild ) - .command( 'off', 'Closes a cloudflare tunnel', () => {}, tunnelOff ) - .help( 'h' ) - .alias( 'h', 'help' ).argv; - -/** - * Save tunnel URL to cloudflared-specific file - * @param {string} url - URL - */ -function setTunnelUrl( url ) { - fs.writeFileSync( config.get( 'dirs.temp' ) + '/cloudflared', url ); -} - -/** - * Fork a subprocess to run the cloudflare tunnel. - * - * @param {object} argv - Args. - * @return {Promise} - */ -async function tunnelOn( argv ) { - const s = argv.logfile ? fs.createWriteStream( argv.logfile, { flags: 'a' } ) : 'ignore'; - if ( argv.logfile ) { - await new Promise( resolve => { - s.on( 'open', resolve ); - } ); - } - - const cp = childProcess.fork( import.meta.url.replace( 'file://', '' ), [ 'child' ], { - detached: true, - stdio: [ 'ignore', s, s, 'ipc' ], - } ); - cp.on( 'exit', code => process.exit( code ) ); - cp.on( 'message', m => { - if ( m === 'ok' ) { - process.exit( 0 ); - } else { - console.log( m ); - } - } ); -} - -/** - * Start a cloudflare tunnel using cloudflared - * - * @return {Promise} - */ -async function tunnelChild() { - process.on( 'disconnect', () => { - delete process.send; - } ); - - // Redirect console stuff to process.send too. - const originalConsoleLog = console.log; - const originalConsoleError = console.error; - - const wrap = - func => - ( ...args ) => { - const message = `[cloudflared manager] ${ args.join( ' ' ) }`; - func( message ); - process.send?.( message ); - }; - console.log = wrap( originalConsoleLog ); - console.error = wrap( originalConsoleError ); - - console.log( 'Starting cloudflared tunnel...' ); - - return new Promise( ( resolve, reject ) => { - const cloudflaredProcess = childProcess.spawn( - 'cloudflared', - [ - 'tunnel', - '--url', - `localhost:${ tunnelConfig.port }`, - '--logfile', - '/tmp/cloudflared.log', - ], - { - stdio: [ 'ignore', 'pipe', 'pipe' ], - } - ); - - let tunnelUrl = ''; - let resolved = false; - - const onData = data => { - const output = data.toString(); - console.log( output ); - - const urlMatch = output.match( /https:\/\/.*\.trycloudflare\.com/ ); - if ( urlMatch && ! resolved ) { - tunnelUrl = urlMatch[ 0 ]; - console.log( `Cloudflare tunnel started: ${ tunnelUrl }` ); - - // Save the tunnel URL and PID immediately - // The connectivity checks will be done by the Playwright test - setTunnelUrl( tunnelUrl ); - fs.writeFileSync( config.get( 'temp.pid' ), `${ cloudflaredProcess.pid }` ); - resolved = true; - process.send?.( 'ok' ); - resolve(); - } - }; - - cloudflaredProcess.stdout.on( 'data', onData ); - cloudflaredProcess.stderr.on( 'data', onData ); - - cloudflaredProcess.on( 'error', error => { - if ( ! resolved ) { - console.error( 'Failed to start cloudflared tunnel:', error ); - reject( error ); - } - } ); - - cloudflaredProcess.on( 'exit', code => { - console.log( `Cloudflared process exited with code ${ code }` ); - if ( ! resolved && code !== 0 ) { - reject( new Error( `Cloudflared exited with code ${ code }` ) ); - } - } ); - - // Timeout after 30 seconds - setTimeout( () => { - if ( ! resolved ) { - console.error( 'Cloudflared tunnel startup timeout' ); - cloudflaredProcess.kill(); - reject( new Error( 'Tunnel startup timeout' ) ); - } - }, 30000 ); - } ); -} - -/** - * Stop the cloudflare tunnel - * - * @return {Promise} - */ -async function tunnelOff() { - log( 'Stopping cloudflared tunnel...' ); - - const pidfile = config.get( 'temp.pid' ); - if ( fs.existsSync( pidfile ) ) { - const pid = fs.readFileSync( pidfile ).toString(); - const processExists = p => { - try { - process.kill( p, 0 ); - return true; - } catch ( e ) { - return e.code !== 'ESRCH'; - } - }; - if ( pid.match( /^\d+$/ ) && processExists( pid ) ) { - log( `Terminating cloudflared process ${ pid }` ); - process.kill( pid ); - await new Promise( resolve => { - const check = () => { - if ( ! processExists( pid ) ) { - resolve(); - } else { - setTimeout( check, 100 ); - } - }; - check(); - } ); - } - fs.unlinkSync( pidfile ); - } - - // Clean up cloudflared tunnel file - const cloudflaredPath = config.get( 'dirs.temp' ) + '/cloudflared'; - if ( fs.existsSync( cloudflaredPath ) ) { - fs.unlinkSync( cloudflaredPath ); - } - - log( 'Cloudflare tunnel stopped' ); -} diff --git a/tools/e2e-commons/bin/localtunnel.js b/tools/e2e-commons/bin/localtunnel.js deleted file mode 100755 index 657d138e44c45..0000000000000 --- a/tools/e2e-commons/bin/localtunnel.js +++ /dev/null @@ -1,308 +0,0 @@ -#!/usr/bin/env node - -import childProcess from 'child_process'; -import fs from 'fs'; -import { fileURLToPath } from 'url'; -import axios from 'axios'; -import config from 'config'; -import localtunnel from 'localtunnel'; -import yargs from 'yargs'; -import { hideBin } from 'yargs/helpers'; - -const tunnelConfig = config.get( 'tunnel' ); - -fs.mkdirSync( config.get( 'dirs.temp' ), { recursive: true } ); - -/** - * Log a message with localtunnel manager prefix - * @param {...*} args - Arguments to log - */ -function log( ...args ) { - console.log( '[localtunnel manager]', ...args ); -} - -/** - * Log an error message with localtunnel manager prefix - * @param {...*} args - Arguments to log - */ -function logError( ...args ) { - console.error( '[localtunnel manager]', ...args ); -} - -/** - * Log a warning message with localtunnel manager prefix - * @param {...*} args - Arguments to log - */ -function logWarn( ...args ) { - console.warn( '[localtunnel manager]', ...args ); -} - -// eslint-disable-next-line @typescript-eslint/no-unused-expressions -yargs( hideBin( process.argv ) ) - .usage( 'Usage: $0 ' ) - .demandCommand( 1, 1 ) - .command( - 'on [logfile]', - 'Opens a local tunnel', - yarg => { - yarg.positional( 'logfile', { - describe: 'File to write tunnel logs to', - type: 'string', - } ); - }, - tunnelOn - ) - .command( 'child', false, () => {}, tunnelChild ) - .command( 'off', 'Closes a local tunnel', () => {}, tunnelOff ) - .help( 'h' ) - .alias( 'h', 'help' ).argv; - -/** - * Reads and returns the content of the file expected to store an URL. - * The file path is stored in config. - * No validation is done on the file content, so an invalid URL can be returned. - * - * @return {string} the file content, or undefined in file doesn't exist or cannot be read - */ -export function getReusableUrlFromFile() { - let urlFromFile; - try { - urlFromFile = fs - .readFileSync( config.get( 'dirs.temp' ) + '/localtunnel', 'utf8' ) - .replace( 'http:', 'https:' ); - } catch ( error ) { - if ( error.code === 'ENOENT' ) { - // We expect this, reduce noise in logs - logWarn( "Localtunnel file doesn't exist" ); - } else { - logError( error ); - } - } - return urlFromFile; -} - -/** - * This allows overriding the tunnel with a custom tunnel like ngrok. - * Useful when running e2e tests locally and you want to use a tunnel that's - * closer to you than the localtunnel instance. - * - * For example: - * ``` - * TUNNEL_URL=https://somethingsomething.ngrok.io npm run test-e2e:start - * ``` - * - * @return {string|undefined} URL - */ -function getTunnelOverrideURL() { - return process.env.TUNNEL_URL; -} - -/** - * Save tunnel URL to file - * @param {string} url - URL - */ -function saveTunnelUrlToFile( url ) { - fs.writeFileSync( config.get( 'dirs.temp' ) + '/localtunnel', url ); -} - -/** - * Fork a subprocess to run the tunnel. - * - * The `localtunnel` needs a process to keep running for the entire time the tunnel is up. - * This function forks a subprocess to do that, then exits when that subprocess indicates - * that the tunnel actually is up so the caller can proceed with running tests or whatever. - * - * @param {object} argv - Args. - * @return {Promise} - */ -async function tunnelOn( argv ) { - const s = argv.logfile ? fs.createWriteStream( argv.logfile, { flags: 'a' } ) : 'ignore'; - if ( argv.logfile ) { - await new Promise( resolve => { - s.on( 'open', resolve ); - } ); - } - - const cp = childProcess.fork( fileURLToPath( import.meta.url ), [ 'child' ], { - detached: true, - stdio: [ 'ignore', s, s, 'ipc' ], - } ); - cp.on( 'exit', code => process.exit( code ) ); - cp.on( 'message', m => { - if ( m === 'ok' ) { - process.exit( 0 ); - } else { - console.log( m ); - } - } ); -} - -/** - * Create a new tunnel based on stored configuration - * If a valid url is saved in the file configured to store it the subdomain will be reused - * Otherwise localtunnel will create randomly assigned subdomain - * Once the tunnel is created its url will be written in the file - * - * @return {Promise} - */ -async function tunnelChild() { - process.on( 'disconnect', () => { - delete process.send; - } ); - - // Redirect console stuff to process.send too. - const originalConsoleLog = console.log; - const originalConsoleError = console.error; - - const wrap = - func => - ( ...args ) => { - const message = `[localtunnel manager] ${ args.join( ' ' ) }`; - func( message ); - process.send?.( message ); - }; - console.log = wrap( originalConsoleLog ); - console.error = wrap( originalConsoleError ); - - const customTunnelUrl = getTunnelOverrideURL(); - if ( customTunnelUrl ) { - console.log( `Using custom tunnel URL: ${ customTunnelUrl }` ); - saveTunnelUrlToFile( customTunnelUrl ); - process.exit( 0 ); - } - - const subdomain = await getTunnelSubdomain(); - - if ( ! ( await isTunnelOn( subdomain ) ) ) { - console.log( `Opening tunnel. Subdomain: '${ subdomain }'` ); - const tunnel = await localtunnel( { - host: tunnelConfig.host, - port: tunnelConfig.port, - subdomain, - } ); - - tunnel.on( 'close', () => { - console.log( `${ tunnel.clientId } tunnel closed` ); - } ); - - console.log( `Opened tunnel '${ tunnel.url }'` ); - saveTunnelUrlToFile( tunnel.url ); - fs.writeFileSync( config.get( 'temp.pid' ), `${ process.pid }` ); - } - - process.send?.( 'ok' ); -} - -/** - * Call {host}/api/tunnels/{subdomain}/delete to stop a tunnel - * Normally the tunnel will get closed if the process running this script is killed. - * This function forces the deletion of a tunnel, just in case things didn't go according to plan - * - * @return {Promise} - */ -async function tunnelOff() { - const subdomain = await getTunnelSubdomain(); - - if ( subdomain ) { - log( `Closing tunnel ${ subdomain }` ); - - const pidfile = config.get( 'temp.pid' ); - if ( fs.existsSync( pidfile ) ) { - const pid = fs.readFileSync( pidfile ).toString(); - const processExists = p => { - try { - process.kill( p, 0 ); - return true; - } catch ( e ) { - return e.code !== 'ESRCH'; - } - }; - if ( pid.match( /^\d+$/ ) && processExists( pid ) ) { - log( `Terminating tunnel process ${ pid }` ); - process.kill( pid ); - await new Promise( resolve => { - const check = () => { - if ( ! processExists( pid ) ) { - resolve(); - } else { - setTimeout( check, 100 ); - } - }; - check(); - } ); - } - fs.unlinkSync( pidfile ); - } - - try { - const res = await axios.get( `${ tunnelConfig.host }/api/tunnels/${ subdomain }/delete` ); - log( JSON.stringify( res.data ) ); - } catch ( error ) { - logError( error.message ); - } - } -} - -/** - * Determines if a tunnel is on by checking the status code of a http call - * If status is 200 we assume the tunnel is on, and off for any other status - * This is definitely not bullet proof, as the tunnel can be on while the app is down, this returning a non 200 response - * - * @param {string} subdomain - tunnel's subdomain - * @return {Promise} tunnel on - true, off - false - */ -async function isTunnelOn( subdomain ) { - console.log( `Checking if tunnel for ${ subdomain } is on` ); - const statusCode = await getTunnelStatus( subdomain ); - - const isOn = statusCode === 200; - let status = 'OFF'; - if ( isOn ) { - status = 'ON'; - } - console.log( `Tunnel for ${ subdomain } is ${ status } (${ statusCode })` ); - return isOn; -} - -/** - * Returns the http status code for tunnel url - * - * @param {string} subdomain - tunnel's subdomain - * @return {Promise} http status code - */ -async function getTunnelStatus( subdomain ) { - let responseStatusCode; - - if ( ! subdomain ) { - console.log( 'Cannot check tunnel for undefined subdomain!' ); - responseStatusCode = 404; - } else { - try { - const res = await axios.get( `${ tunnelConfig.host }/api/tunnels/${ subdomain }/status` ); - console.log( res.status ); - responseStatusCode = res.status; - } catch ( error ) { - console.error( error.message ); - } - } - return responseStatusCode; -} - -/** - * Resolves the subdomain of a url written in file - * - * @return {Promise<*>} subdomain or undefined if file not found or subdomain cannot be extracted - */ -async function getTunnelSubdomain() { - let subdomain; - - // Try to re-use the subdomain by using the tunnel url saved in file. - // If a valid url is not found do not fail, but create a tunnel - // with a randomly assigned subdomain (default option) - const urlFromFile = getReusableUrlFromFile(); - - if ( urlFromFile && new URL( urlFromFile ) ) { - subdomain = urlFromFile.replace( /.*?:\/\//g, '' ).split( '.' )[ 0 ]; - } - return subdomain; -} diff --git a/tools/e2e-commons/bin/tunnel.sh b/tools/e2e-commons/bin/tunnel.sh index ed18099ff1bf3..4ffaac01d0769 100755 --- a/tools/e2e-commons/bin/tunnel.sh +++ b/tools/e2e-commons/bin/tunnel.sh @@ -6,6 +6,7 @@ # # Environment Variables: # - USE_CLOUDFLARE_TUNNEL: Use Cloudflare Tunnel instead of LocalTunnel +# - TUNNEL_DEBUG: Enable verbose debug logging # set -e @@ -26,10 +27,26 @@ function usage() { BASE_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" export PATH="$BASE_DIR/../node_modules/.bin:$PATH" +TUNNEL_CLI_COMMAND="node $BASE_DIR/tunnel/tunnel-cli.js" + +if [[ -n "${USE_CLOUDFLARE_TUNNEL}" ]]; then + TUNNEL_CLI_COMMAND="$TUNNEL_CLI_COMMAND --provider cloudflared" +else + TUNNEL_CLI_COMMAND="$TUNNEL_CLI_COMMAND --provider localtunnel" +fi + function log() { echo "[tunnel manager] $*" } +function debug_log() { + if [[ -n "${TUNNEL_DEBUG}" ]]; then + echo "[tunnel manager DEBUG] $*" + fi +} + +debug_log "TUNNEL_CLI_COMMAND: $TUNNEL_CLI_COMMAND" + function health_check() { local url="$1" local max_attempts=20 @@ -92,15 +109,14 @@ function up() { if [ $retry_count -gt 0 ]; then log "Retrying tunnel setup (attempt $((retry_count + 1))/$((max_retries + 1)))..." fi - + + log "Closing potentially running tunnel..." down + + log "Opening new tunnel..." + debug_log "Executing: $TUNNEL_CLI_COMMAND on $*" local tunnel_output - if [[ -n "${USE_CLOUDFLARE_TUNNEL}" ]]; then - tunnel_output=$(node "$BASE_DIR"/cloudflaretunnel.js on "$@" 2>&1) - else - tunnel_output=$(node "$BASE_DIR"/localtunnel.js on "$@" 2>&1) - fi - + tunnel_output=$($TUNNEL_CLI_COMMAND on "$@" 2>&1) echo "$tunnel_output" # Extract tunnel URL from the startup output @@ -125,16 +141,15 @@ function up() { } function down() { - if [[ -n "${USE_CLOUDFLARE_TUNNEL}" ]]; then - node "$BASE_DIR"/cloudflaretunnel.js off - else - node "$BASE_DIR"/localtunnel.js off - fi + debug_log "Executing: $TUNNEL_CLI_COMMAND off" + $TUNNEL_CLI_COMMAND off } function reset() { down - rm -rf config/tmp + log "Resetting tunnel..." + debug_log "Executing: $TUNNEL_CLI_COMMAND clear" + $TUNNEL_CLI_COMMAND clear up } diff --git a/tools/e2e-commons/bin/tunnel/README.md b/tools/e2e-commons/bin/tunnel/README.md new file mode 100644 index 0000000000000..5e04585ff5ce2 --- /dev/null +++ b/tools/e2e-commons/bin/tunnel/README.md @@ -0,0 +1,43 @@ +# Tunnel Management + +Tunnel management system for E2E testing with support for multiple providers. + +## Usage + +```bash +# Start tunnel (defaults to localtunnel) +node tunnel-cli.js on + +# Start with specific provider +node tunnel-cli.js on --provider cloudflared + +# Stop tunnel +node tunnel-cli.js off --provider cloudflared + +# Clear stored data +node tunnel-cli.js clear --provider cloudflared + +# Print help +node tunnel-cli.js --help +``` + +## Providers + +### LocalTunnel +- Default provider +- No additional setup required, it's already part of node dependencies +- Uses random subdomains +- Can reuse a tunnel subdomain if you need to use the same site with the same URL + +### Cloudflared +- Requires cloudflared binary installed. [See docs](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/do-more-with-tunnels/local-management/create-local-tunnel/#1-download-and-install-cloudflared) for instructions. +- Better concurrency performance + +## Stored data + +Tunnel state (URL and PID) is stored in files. + +- `{temp_dir}/localtunnel-url` - Stored tunnel URL +- `{temp_dir}/localtunnel-pid` - Process ID file +- `{temp_dir}/cloudflared-url` - Cloudflare tunnel URL +- `{temp_dir}/cloudflared-pid` - Cloudflare process ID diff --git a/tools/e2e-commons/bin/tunnel/cloudflared.js b/tools/e2e-commons/bin/tunnel/cloudflared.js new file mode 100644 index 0000000000000..6e0a6e904057f --- /dev/null +++ b/tools/e2e-commons/bin/tunnel/cloudflared.js @@ -0,0 +1,81 @@ +import childProcess from 'child_process'; +import { TunnelManager } from './tunnel.js'; + +export default class CloudflaredProvider extends TunnelManager { + constructor() { + super( 'cloudflared' ); + } + + /** + * Start the cloudflared tunnel + * @return {Promise} + */ + async start() { + console.log( 'Starting cloudflared tunnel...' ); + + return new Promise( ( resolve, reject ) => { + const cloudflaredProcess = childProcess.spawn( + 'cloudflared', + [ 'tunnel', '--url', `localhost:${ this.config.port }` ], + { + stdio: [ 'ignore', 'pipe', 'pipe' ], + } + ); + + let tunnelUrl = ''; + let resolved = false; + + const onData = data => { + const output = data.toString(); + console.log( output ); + + const urlMatch = output.match( /https:\/\/.*\.trycloudflare\.com/ ); + if ( urlMatch && ! resolved ) { + tunnelUrl = urlMatch[ 0 ]; + console.log( `Cloudflare tunnel started: ${ tunnelUrl }` ); + + this.storeUrl( tunnelUrl ); + this.storePid( cloudflaredProcess.pid ); + resolved = true; + resolve(); + } + }; + + cloudflaredProcess.stdout.on( 'data', onData ); + cloudflaredProcess.stderr.on( 'data', onData ); + + cloudflaredProcess.on( 'error', error => { + if ( ! resolved ) { + console.error( 'Failed to start cloudflared tunnel:', error ); + reject( error ); + } + } ); + + cloudflaredProcess.on( 'exit', code => { + console.log( `Cloudflared process exited with code ${ code }` ); + if ( ! resolved && code !== 0 ) { + reject( new Error( `Cloudflared exited with code ${ code }` ) ); + } + } ); + + // Timeout after 30 seconds + setTimeout( () => { + if ( ! resolved ) { + console.error( 'Cloudflared tunnel startup timeout' ); + cloudflaredProcess.kill(); + reject( new Error( 'Tunnel startup timeout' ) ); + } + }, 30000 ); + } ); + } + + /** + * Stop the cloudflared tunnel + * @return {Promise} + */ + async stop() { + this.log( 'Stopping cloudflared tunnel...' ); + this.clearUrl(); + this.log( 'Cloudflare tunnel stopped' ); + } +} diff --git a/tools/e2e-commons/bin/tunnel/localtunnel.js b/tools/e2e-commons/bin/tunnel/localtunnel.js new file mode 100644 index 0000000000000..2f5cb233cbab5 --- /dev/null +++ b/tools/e2e-commons/bin/tunnel/localtunnel.js @@ -0,0 +1,75 @@ +import axios from 'axios'; +import localtunnel from 'localtunnel'; +import { TunnelManager } from './tunnel.js'; + +export default class LocalTunnelProvider extends TunnelManager { + constructor() { + super( 'localtunnel' ); + } + + /** + * Start the localtunnel. If a stored URL is found, it will be reused. + * @return {Promise} + */ + async start() { + console.log( 'Starting localtunnel...' ); + const subdomain = this.getTunnelSubdomain(); + + console.log( `Opening tunnel. Subdomain: '${ subdomain }'` ); + const tunnel = await localtunnel( { + host: this.config.host, + port: this.config.port, + subdomain, + } ); + + tunnel.on( 'close', () => { + console.log( `${ tunnel.clientId } tunnel closed` ); + } ); + + console.log( `Opened tunnel '${ tunnel.url }'` ); + this.storeUrl( tunnel.url ); + this.storePid( process.pid ); + } + + /** + * Stop the localtunnel + * @return {Promise} + */ + async stop() { + const subdomain = this.getTunnelSubdomain(); + + if ( subdomain ) { + this.log( `Closing tunnel ${ subdomain }` ); + try { + this.log( `Sending delete request for ${ subdomain }` ); + const res = await axios.get( `${ this.config.host }/api/tunnels/${ subdomain }/delete` ); + this.log( JSON.stringify( res.data ) ); + } catch ( error ) { + this.logError( error.message ); + } + } + } + + /** + * Get tunnel HTTP status code + * @param {string} subdomain - Tunnel subdomain + * @return {Promise} HTTP status code + */ + async getTunnelStatus( subdomain ) { + let responseStatusCode; + + if ( ! subdomain ) { + console.log( 'Cannot check tunnel for undefined subdomain!' ); + responseStatusCode = 404; + } else { + try { + const res = await axios.get( `${ this.config.host }/api/tunnels/${ subdomain }/status` ); + console.log( res.status ); + responseStatusCode = res.status; + } catch ( error ) { + console.error( error.message ); + } + } + return responseStatusCode; + } +} diff --git a/tools/e2e-commons/bin/tunnel/tunnel-cli.js b/tools/e2e-commons/bin/tunnel/tunnel-cli.js new file mode 100644 index 0000000000000..374585e848db0 --- /dev/null +++ b/tools/e2e-commons/bin/tunnel/tunnel-cli.js @@ -0,0 +1,110 @@ +#!/usr/bin/env node + +import childProcess from 'child_process'; +import fs from 'fs'; +import { fileURLToPath } from 'url'; +import yargs from 'yargs'; +import { hideBin } from 'yargs/helpers'; + +const SUPPORTED_PROVIDERS = [ 'localtunnel', 'cloudflared' ]; + +/** + * Get the appropriate tunnel manager instance based on provider + * @param {string} providerName - Provider name + * @return {Promise} Tunnel manager instance + */ +async function getTunnelManager( providerName ) { + if ( ! SUPPORTED_PROVIDERS.includes( providerName ) ) { + throw new Error( + `Unsupported provider: ${ providerName }. Supported providers: ${ SUPPORTED_PROVIDERS.join( + ', ' + ) }` + ); + } + const providerModule = await import( `./${ providerName }.js` ); + const ProviderClass = providerModule.default; + return new ProviderClass(); +} + +/** + * Fork a subprocess to run the tunnel + * @param {object} argv - Args + * @param {string} providerName - Provider name + * @return {Promise} + */ +async function tunnelOn( argv, providerName ) { + const s = argv.logfile ? fs.createWriteStream( argv.logfile, { flags: 'a' } ) : 'ignore'; + if ( argv.logfile ) { + await new Promise( resolve => { + s.on( 'open', resolve ); + } ); + } + + const cliPath = fileURLToPath( import.meta.url ); + const args = [ 'child', '--provider', providerName ]; + const cp = childProcess.fork( cliPath, args, { + detached: true, + stdio: [ 'ignore', s, s, 'ipc' ], + } ); + cp.on( 'exit', code => process.exit( code ) ); + cp.on( 'message', m => { + if ( m === 'ok' ) { + process.exit( 0 ); + } else { + console.log( m ); + } + } ); +} + +// eslint-disable-next-line @typescript-eslint/no-unused-expressions +yargs( hideBin( process.argv ) ) + .usage( 'Usage: $0 ' ) + .demandCommand( 1, 1 ) + .option( 'provider', { + alias: 'p', + describe: 'Tunnel provider to use', + choices: SUPPORTED_PROVIDERS, + default: 'localtunnel', + } ) + .command( + 'on [logfile]', + 'Opens a tunnel', + yarg => { + yarg.positional( 'logfile', { + describe: 'File to write tunnel logs to', + type: 'string', + } ); + }, + async argv => { + await tunnelOn( argv, argv.provider ); + } + ) + .command( + 'child', + false, + () => {}, + async argv => { + const manager = await getTunnelManager( argv.provider ); + await manager.tunnelChild( argv.provider ); + } + ) + .command( + 'off', + 'Closes a tunnel', + () => {}, + async argv => { + const manager = await getTunnelManager( argv.provider ); + await manager.tunnelOff(); + } + ) + .command( + 'clear', + 'Clears all stored tunnel data (URL and PID files)', + () => {}, + async argv => { + const manager = await getTunnelManager( argv.provider ); + manager.clear(); + } + ) + .help( 'h' ) + .alias( 'h', 'help' ).argv; diff --git a/tools/e2e-commons/bin/tunnel/tunnel.js b/tools/e2e-commons/bin/tunnel/tunnel.js new file mode 100755 index 0000000000000..e40e01f7dc7e7 --- /dev/null +++ b/tools/e2e-commons/bin/tunnel/tunnel.js @@ -0,0 +1,255 @@ +import fs from 'fs'; +import path from 'path'; +import config from 'config'; + +export class TunnelManager { + constructor( providerName ) { + this.providerName = providerName; + this.config = config.get( 'tunnel' ); + + fs.mkdirSync( config.get( 'dirs.temp' ), { recursive: true } ); + + this.urlFile = path.resolve( `${ config.get( 'dirs.temp' ) }/${ providerName }-url` ); + this.pidFile = path.resolve( `${ config.get( 'dirs.temp' ) }/${ providerName }-pid` ); + } + + /** + * Log a message with provider-specific prefix + * @param {...*} args - Arguments to log + */ + log( ...args ) { + console.log( `[${ this.providerName } manager]`, ...args ); + } + + /** + * Log an error message with provider-specific prefix + * @param {...*} args - Arguments to log + */ + logError( ...args ) { + console.error( `[${ this.providerName } manager]`, ...args ); + } + + /** + * Log a warning message with provider-specific prefix + * @param {...*} args - Arguments to log + */ + logWarn( ...args ) { + console.warn( `[${ this.providerName } manager]`, ...args ); + } + + /** + * Log a debug message with provider-specific prefix (only if TUNNEL_DEBUG is enabled) + * @param {...*} args - Arguments to log + */ + logDebug( ...args ) { + if ( process.env.TUNNEL_DEBUG ) { + console.log( `[${ this.providerName } manager]`, ...args ); + } + } + + /** + * Run the tunnel in child process + * @param {string} providerName - Provider name + * @return {Promise} + */ + async tunnelChild( providerName ) { + process.on( 'disconnect', () => { + delete process.send; + } ); + + // Redirect console stuff to process.send too + const originalConsoleLog = console.log; + const originalConsoleError = console.error; + + const wrap = + func => + ( ...args ) => { + const message = args.join( ' ' ); + const prefixedMessage = `[${ providerName } manager] ${ message }`; + func( prefixedMessage ); + process.send?.( prefixedMessage ); + }; + console.log = wrap( originalConsoleLog ); + console.error = wrap( originalConsoleError ); + + await this.start(); + process.send?.( 'ok' ); + } + + /** + * Stop the tunnel + * @return {Promise} + */ + async tunnelOff() { + await this.stop(); + await this.genericStop(); + } + + /** + * Generic stop logic for process management + * @return {Promise} + */ + async genericStop() { + this.log( `Killing ${ this.providerName } process...` ); + + const pid = this.getPid(); + if ( pid && pid.match( /^\d+$/ ) && this.processExists( pid ) ) { + this.log( `Terminating ${ this.providerName } process ${ pid }` ); + process.kill( pid ); + await this.waitForProcessExit( pid ); + } + + // Clean up PID file + this.clearPid(); + } + + /** + * Check if process exists + * @param {string|number} pid - Process ID + * @return {boolean} Process exists + */ + processExists( pid ) { + try { + process.kill( pid, 0 ); + return true; + } catch ( e ) { + return e.code !== 'ESRCH'; + } + } + + /** + * Wait for process to exit + * @param {string|number} pid - Process ID + * @return {Promise} + */ + waitForProcessExit( pid ) { + return new Promise( resolve => { + const check = () => { + if ( ! this.processExists( pid ) ) { + resolve(); + } else { + setTimeout( check, 100 ); + } + }; + check(); + } ); + } + + /** + * Save tunnel URL to file + * @param {string} url - URL + */ + storeUrl( url ) { + fs.writeFileSync( this.urlFile, url ); + } + + /** + * Write PID file + * @param {number} pid - Process ID + */ + storePid( pid ) { + fs.writeFileSync( this.pidFile, `${ pid }` ); + } + + /** + * Get stored PID + * @return {string|null} PID or null if not found + */ + getPid() { + if ( fs.existsSync( this.pidFile ) ) { + this.logDebug( `Found stored PID for ${ this.providerName }: ${ this.pidFile }` ); + return fs.readFileSync( this.pidFile ).toString().trim(); + } + this.logWarn( + `Cannot find stored PID for ${ this.providerName }. Looking for ${ this.pidFile } file` + ); + return null; + } + + /** + * Clear/remove PID file + */ + clearPid() { + if ( fs.existsSync( this.pidFile ) ) { + fs.unlinkSync( this.pidFile ); + this.logDebug( `Removed ${ this.pidFile }` ); + } + } + + /** + * Get stored URL + * @return {string|null} URL or null if not found + */ + getUrl() { + if ( fs.existsSync( this.urlFile ) ) { + this.logDebug( `Found stored URL for ${ this.providerName }: ${ this.urlFile }` ); + return fs.readFileSync( this.urlFile ).toString().trim(); + } + this.logWarn( + `Cannot find stored URL for ${ this.providerName }. Looking for ${ this.urlFile } file` + ); + return null; + } + + /** + * Clear/remove URL file + */ + clearUrl() { + if ( fs.existsSync( this.urlFile ) ) { + fs.unlinkSync( this.urlFile ); + this.logDebug( `Removed ${ this.urlFile }` ); + } else { + this.logWarn( + `Cannot find stored URL for ${ this.providerName }. Looking for ${ this.urlFile } file` + ); + } + } + + /** + * Get tunnel subdomain from stored URL + * @return {string|undefined} Subdomain or undefined if no URL or invalid URL + */ + getTunnelSubdomain() { + const urlFromFile = this.getUrl(); + + if ( urlFromFile ) { + try { + const url = new URL( urlFromFile ); + const hostname = url.hostname; + const subdomain = hostname.split( '.' )[ 0 ]; + return subdomain; + } catch { + this.logWarn( `Invalid URL format in stored URL: ${ urlFromFile }` ); + return undefined; + } + } + return undefined; + } + + /** + * Get tunnel hostname from stored URL + * @return {string|undefined} Hostname or undefined if no URL or invalid URL + */ + getTunnelHostname() { + const urlFromFile = this.getUrl(); + + if ( urlFromFile ) { + try { + const url = new URL( urlFromFile ); + return url.hostname; + } catch { + this.logWarn( `Invalid URL format in stored URL: ${ urlFromFile }` ); + return undefined; + } + } + return undefined; + } + + /** + * Clear all stored tunnel data (URL and PID files) + */ + clear() { + this.clearUrl(); + this.clearPid(); + } +} diff --git a/tools/e2e-commons/utils/environment.ts b/tools/e2e-commons/utils/environment.ts index 7465798525f00..02ffcb261ff4b 100644 --- a/tools/e2e-commons/utils/environment.ts +++ b/tools/e2e-commons/utils/environment.ts @@ -74,7 +74,7 @@ export function resolveSiteUrl(): string { } else if ( process.env.USE_CLOUDFLARE_TUNNEL ) { logger.debug( 'USE_CLOUDFLARE_TUNNEL is set, checking cloudflared tunnel file' ); - const cloudflaredPath = config.get( 'dirs.temp' ) + '/cloudflared'; + const cloudflaredPath = config.get( 'dirs.temp' ) + '/cloudflared-url'; try { url = fs.readFileSync( cloudflaredPath, 'utf8' ).replace( 'http:', 'https:' ); logger.debug( `Using cloudflared tunnel URL from file: ${ url }` ); @@ -89,7 +89,7 @@ export function resolveSiteUrl(): string { logger.debug( 'Checking for localtunnel url' ); // Check localtunnel file first - const localtunnelPath = config.get( 'dirs.temp' ) + '/localtunnel'; + const localtunnelPath = config.get( 'dirs.temp' ) + '/localtunnel-url'; try { url = fs.readFileSync( localtunnelPath, 'utf8' ).replace( 'http:', 'https:' ); logger.debug( `Using localtunnel URL from file: ${ url }` );