UNPKG

testingbot-tunnel-launcher

Version:
375 lines (315 loc) 11.4 kB
'use strict' const fs = require('fs') const fsp = fs.promises const os = require('os') const path = require('path') const { spawn } = require('child_process') const downloader = require('./downloader') let tunnelLocation let activeTunnel let started = false const MIN_JAVA_VERSION = 11 const DEFAULT_TIMEOUT = 90 function parseJavaVersion (versionOutput) { const versionMatch = versionOutput.match(/version "(\d+)/) if (!versionMatch) { return null } return parseInt(versionMatch[1], 10) } function validateJavaVersion (versionOutput) { const majorVersion = parseJavaVersion(versionOutput) if (majorVersion === null) { return { valid: false, version: null, error: 'Could not determine Java version. Please ensure Java 11 or higher is installed for testingbot-tunnel.' } } if (majorVersion < MIN_JAVA_VERSION) { return { valid: false, version: majorVersion, error: `Java ${majorVersion} is installed, but Java ${MIN_JAVA_VERSION} or higher is required for testingbot-tunnel.` } } return { valid: true, version: majorVersion, error: null } } /** * Validate options passed to the tunnel launcher * @param {Object} options * @throws {Error} If options are invalid */ function validateOptions (options) { if (options.apiKey !== undefined && typeof options.apiKey !== 'string') { throw new Error('apiKey must be a string') } if (options.apiSecret !== undefined && typeof options.apiSecret !== 'string') { throw new Error('apiSecret must be a string') } if (typeof options.apiKey === 'string' && options.apiKey.trim() === '') { throw new Error('apiKey cannot be empty') } if (typeof options.apiSecret === 'string' && options.apiSecret.trim() === '') { throw new Error('apiSecret cannot be empty') } if (options.tunnelVersion !== undefined && typeof options.tunnelVersion !== 'string') { throw new Error('tunnelVersion must be a string') } if (options.tunnelIdentifier !== undefined && typeof options.tunnelIdentifier !== 'string') { throw new Error('tunnelIdentifier must be a string') } if (options.timeout !== undefined && (typeof options.timeout !== 'number' || options.timeout <= 0)) { throw new Error('timeout must be a positive number') } if (options.shared !== undefined && typeof options.shared !== 'boolean') { throw new Error('shared must be a boolean') } } /** * Check if Java is installed and meets minimum version requirement * @returns {Promise<{version: number}>} */ async function checkJava () { return new Promise((resolve, reject) => { const checkJava = spawn('java', ['-version']) let javaVersionOutput = '' checkJava.on('error', err => { reject(new Error(`Java might not be installed or not in $PATH. Java is necessary to use testingbot-tunnel ${err.message}`)) }) checkJava.stderr.on('data', data => { javaVersionOutput += data.toString() }) checkJava.on('close', () => { const result = validateJavaVersion(javaVersionOutput) if (!result.valid) { if (result.version === null) { console.warn(result.error) resolve({ version: null }) } else { reject(new Error(result.error)) } } else { resolve({ version: result.version }) } }) }) } /** * Download the tunnel JAR file * @param {Object} options * @returns {Promise<void>} */ async function downloadAsync (options = {}) { tunnelLocation = path.normalize(path.join(__dirname, '../testingbot-tunnel.jar')) let url = 'https://testingbot.com/tunnel/testingbot-tunnel.jar' if (options.tunnelVersion) { tunnelLocation = path.normalize(path.join(__dirname, `../testingbot-tunnel-${options.tunnelVersion}.jar`)) url = `https://testingbot.com/tunnel/testingbot-tunnel-${options.tunnelVersion}.jar` } const exists = fs.existsSync(tunnelLocation) if (exists) { const isValid = await new Promise(resolve => { const validateProcess = spawn('java', ['-jar', tunnelLocation, '-h']) let hasError = false let stderrOutput = '' validateProcess.stderr.on('data', data => { stderrOutput += data.toString() }) validateProcess.on('error', () => { hasError = true }) validateProcess.on('close', code => { if (hasError || code !== 0 || stderrOutput) { console.log('Found a cached testingbot-tunnel.jar file, but it might be corrupt. Redownloading.') resolve(false) } else { resolve(true) } }) }) if (isValid) { return } } return new Promise((resolve, reject) => { downloader.get(url, { fileName: 'testingbot-tunnel', destination: tunnelLocation }, (err) => { if (err) { reject(new Error(`Could not download the tunnel from TestingBot - please check your connection. ${err.message}`)) } else { resolve() } }) }) } function createArgs (options) { const args = [] args.push('-jar') args.push(tunnelLocation) const optionMapping = { 'tunnelIdentifier': 'tunnel-identifier', 'noBump': 'nobump', 'noCache': 'nocache', 'shared': 'shared' } if (options.apiKey) { args.push(options.apiKey) } if (options.apiSecret) { args.push(options.apiSecret) } for (const option in options) { if ((option === 'apiKey') || (option === 'apiSecret') || (option === 'verbose') || (option === 'tunnelVersion')) { continue } const optionName = optionMapping[option] || option if (options[option] && typeof (options[option]) === 'string') { args.push(`--${optionName}`) args.push(options[option]) } else if (options[option]) { args.push(`--${optionName}`) } } return args } /** * Start the tunnel process * @param {Object} options * @returns {Promise<ChildProcess>} */ async function startTunnelAsync (options = {}) { const readyFile = path.join(os.tmpdir(), 'testingbot.ready') // Clean up existing ready file try { await fsp.unlink(readyFile) console.log('Tunnel Readyfile already exists, removing') } catch (ignore) {} const args = createArgs(options) args.push('-f') args.push(readyFile) if (options.verbose) { console.log('Starting tunnel with options', args) } activeTunnel = spawn('java', args, {}) return new Promise((resolve, reject) => { let waitCounter = 0 let settled = false const timeout = options.timeout || DEFAULT_TIMEOUT const onReady = () => { if (settled) return settled = true started = true clearInterval(readyFileChecker) console.log('Tunnel is ready') resolve(activeTunnel) } const onError = (error) => { if (settled) return settled = true clearInterval(readyFileChecker) reject(error) } const checkReadyFile = async () => { try { await fsp.access(readyFile, fs.constants.F_OK) onReady() } catch { waitCounter += 1 if (waitCounter > timeout) { const errorMessage = `Tunnel failed to launch in ${waitCounter} seconds.` console.log(errorMessage) onError(new Error(errorMessage)) } } } const readyFileChecker = setInterval(checkReadyFile, 1000) activeTunnel.stderr.on('data', data => { data = data.toString().trim() if (options.verbose && data !== '') { console.log(data) } if (data.indexOf('is available for download') > -1) { console.log(data) } if (data.indexOf('401 Unauthorized') > -1) { activeTunnel.error = 'Invalid credentials. Please supply the correct key/secret obtained from TestingBot.com' activeTunnel.close() } else if (data.indexOf('minutes left') > -1) { activeTunnel.error = 'You do not have any minutes left. Please upgrade your account at TestingBot.com' activeTunnel.close() } }) activeTunnel.stdout.on('data', data => { data = data.toString().trim() if (options.verbose && data !== '') { console.log(data) } }) let closing = false activeTunnel.close = closeCallback => { if (closeCallback) { activeTunnel.once('close', closeCallback) } if (!closing) { closing = true activeTunnel.kill('SIGINT') } } activeTunnel.on('exit', (code, signal) => { if (options.verbose) { console.log('Closing TestingBot Tunnel') } if (!started) { onError(new Error(activeTunnel.error ? activeTunnel.error : `Could not start TestingBot Tunnel. Exit code ${code} signal: ${signal}`)) } started = false activeTunnel = null }) }) } /** * Download and run the tunnel (async version) * @param {Object} options * @returns {Promise<ChildProcess>} */ async function downloadAndRunAsync (options = {}) { validateOptions(options) await downloadAsync(options) if (!fs.existsSync(tunnelLocation)) { throw new Error(`Tunnel jar file is not present in ${tunnelLocation}`) } await checkJava() return startTunnelAsync(options) } /** * Kill the active tunnel (async version) * @returns {Promise<void>} */ async function killTunnelAsync () { if (!activeTunnel) { throw new Error('no active tunnel') } activeTunnel.kill('SIGINT') } function downloadAndRun (options, callback) { if (!options) { options = {} } if (!callback) { callback = function () {} } downloadAndRunAsync(options) .then(tunnel => callback(null, tunnel)) .catch(err => callback(err)) } function killTunnel (callback) { if (!callback) { callback = function () {} } killTunnelAsync() .then(() => callback(null)) .catch(err => callback(err)) } module.exports = downloadAndRun module.exports.kill = killTunnel module.exports.createArgs = createArgs module.exports.checkJava = checkJava module.exports.parseJavaVersion = parseJavaVersion module.exports.validateJavaVersion = validateJavaVersion module.exports.validateOptions = validateOptions module.exports.downloadAndRunAsync = downloadAndRunAsync module.exports.killAsync = killTunnelAsync module.exports.downloadAsync = downloadAsync module.exports.startTunnelAsync = startTunnelAsync