UNPKG

nightwatch

Version:

Easy to use Node.js based end-to-end testing solution for web applications using the W3C WebDriver API.

362 lines (281 loc) 10.1 kB
/** Parts of the code are taken from angular-cli code base which is governed by the following license: * https://angular.io/license */ const os = require('os'); const https = require('https'); const uuid = require('uuid'); const fs = require('fs').promises; const path = require('path'); const {execSync} = require('child_process'); const {Logger, VERSION, fileExists} = require('./'); const GA_SERVER_URL = 'https://www.google-analytics.com'; const GA_SERVER_PORT = '443'; const GA_API_KEY = 'XuPojOTwQ6yTO758EV4hBg'; const GA_TRACKING_ID = 'G-DEKPKZSLXS'; const defaultSettings = { enabled: false, log_path: './logs/analytics', client_id: uuid.v4() }; const RESERVED_EVENT_NAMES = ['ad_activeview', 'ad_click', 'ad_exposure', 'ad_impression', 'ad_query', 'adunit_exposure', 'app_clear_data', 'app_install', 'app_update', 'app_remove', 'error', 'first_open', 'first_visit', 'in_app_purchase', 'notification_dismiss', 'notification_foreground', 'notification_open', 'notification_receive', 'os_update', 'screen_view', 'session_start', 'user_engagement']; const ALLOWED_ERRORS = ['Error', 'SyntaxError', 'TypeError', 'ReferenceError', 'WebDriverError', 'TimeoutError', 'NotFoundError', 'NoSuchElementError', 'IosSessionNotCreatedError', 'AndroidConnectionError', 'AndroidBinaryError', 'RealIosDeviceIdError', 'AndroidHomeError']; const RESERVED_EVENT_PARAMETERS = ['firebase_conversion']; const MAX_PARAMETERS_LENGTH = 40; const SYSTEM_LANGUAGE = getLanguage(); /** * See: https://developers.google.com/analytics/devguides/collection/protocol/v1/devguide */ class AnalyticsCollector { constructor() { this.queueLength = 0; this.parameters = {}; this.logger = Logger; this.userAgentString = buildUserAgentString(); this.runId = uuid.v4(); // This identifies unique users and helps us separate out sessions. this.parameters['client_id'] = uuid.v4(); // Prevents google from indexing the events for ad targeting. this.parameters['non_personalized_ads'] = true; } initialize() { if (this.initialized) {return this.initialized} this.initialized = new Promise((resolve) => { // update queueLength fs.stat(this.__getLogFileLocation()) .then((length) => { this.queueLength = length.size / 400; // the actual value doesn't matter resolve(); }) .catch((err)=>{ // ignore error resolve(); }); }); return this.initialized; } updateSettings(settings) { this.settings = settings; if (!this.settings.usage_analytics) { this.settings.usage_analytics = defaultSettings; } this.testEnv = settings.testEnv; // update client_id this.parameters['client_id'] = this.settings.usage_analytics.client_id; } updateLogger(logger) { this.logger = logger; } async collectEvent(name, parameters = {}) { if (!this.settings.usage_analytics.enabled) { return; } await this.initialize(); // Not all shape of events are supported. this.__validateEvent(name, parameters); // Add event timestamp. parameters['event_time'] = new Date().getTime() * 1000; // Add environment information. parameters['env_os'] = this.userAgentString; parameters['env_lang'] = SYSTEM_LANGUAGE; parameters['env_nw_version'] = VERSION.full; parameters['env_node_version'] = `node ${process.version}`; parameters['test_env'] = this.testEnv; parameters['run_id'] = this.runId; return await this.__addToQueue(name, parameters); } async collectErrorEvent(error, isUncaught = false) { if (!error) { return; } await this.initialize(); let errorName = error.name; if (!ALLOWED_ERRORS.includes(errorName)) { errorName = 'UserGeneratedError'; } try { const parameters = { env_os: this.userAgentString, env_nw_version: VERSION.full, env_node_version: `node ${process.version}`, test_env: this.testEnv, err_name: errorName, is_uncaught: isUncaught, run_id: this.runId }; this.__validateEvent('nw_test_err', parameters); await this.__addToQueue('nw_test_err', parameters); if (isUncaught) { await this.__flush(); } } catch (e) { // Ignore } } async __flush() { if (!this.settings.usage_analytics.enabled) { return; } if (this.queueLength === 0) { return; } try { const logfile = this.__getLogFileLocation(); const pendingLogs = await fs.readFile(logfile, 'utf8'); // The below is needed so that if flush is called multiple times, // we don't report the same event multiple times. await fs.truncate(logfile); this.queueLength = 0; const pendingLogsArray = pendingLogs.split('\n'); pendingLogsArray.pop(); const pendingTrackingEvents = pendingLogsArray.map(log => JSON.parse(log)); // Time when the request is sent, expected in micro seconds. const timestamp_micros = new Date().getTime() * 1000; const payload = { ...this.parameters, timestamp_micros, events: pendingTrackingEvents }; await this.__send(payload); } catch (error) { // Failure to report analytics shouldn't crash the system. this.logger.info('Analytics flush error:', error.message); } } async __addToQueue(eventType, parameters) { if (!this.settings.usage_analytics.enabled) { return; } this.queueLength++; const writeLogPromise = await this.__logAnalyticsEvents({name: eventType, params: parameters}); // periodically flush if (this.queueLength > 5) { await this.__flush(); } return writeLogPromise; } async __send(data) { if (!this.settings.usage_analytics.enabled) { return; } this.logger.info('Analytics send event:', data); const path = this.__getGoogleAnalyticsPath(); const serverUrl = new URL(this.settings.usage_analytics.serverUrl || GA_SERVER_URL) ; const serverPort = serverUrl.port || this.settings.usage_analytics.serverPort || GA_SERVER_PORT; const payload = JSON.stringify(data); const request = new https.request({ hostname: serverUrl.hostname, port: serverPort, method: 'POST', path, headers: { 'Content-Type': 'application/json', 'Content-Length': payload.length } }); return new Promise((resolve, reject) => { request.write(payload); request.once('response', (response) => { if (response.statusCode !== 204) { reject( new Error(`Analytics reporting failed with status code: ${response.statusCode}.`) ); } else { resolve(response); } response.on('data', (_) => {}); }); request.on('error', (result) => { new Error(`Failed to send usage metric: ${result.code}.`); reject(result); }); request.end(); }); } __getGoogleAnalyticsPath() { const apiKey = this.settings.usage_analytics.apiKey || GA_API_KEY; const trackingId = this.settings.usage_analytics.trackingId || GA_TRACKING_ID; return `/mp/collect?api_secret=${apiKey}&measurement_id=${trackingId}`; } async __logAnalyticsEvents(data) { const logfile = this.__getLogFileLocation(); const hasAnalyticsLog = await fileExists(logfile); if (!hasAnalyticsLog) { data.params = data.params ? data.params : {}; data.params['first_run'] = true; await fs.mkdir(path.dirname(logfile), {recursive: true}); } const writeFn = hasAnalyticsLog ? fs.appendFile : fs.writeFile; try { await writeFn(logfile, JSON.stringify(data) + '\n'); // Always send data for first runs, this helps us detect CI builds also. if (!hasAnalyticsLog) { await this.__flush(); } } catch (err) { this.logger.warn('Failed to log usage data:', err.message); } } __getLogFileLocation() { const log_path = this.settings.usage_analytics.log_path || './logs/analytics'; return path.resolve(log_path, 'analytics.log'); } __validateEvent(name, parameters) { Object.keys(parameters).forEach(key => { if (parameters[key] === undefined || parameters[key] === null) { parameters[key] = 'undefined'; } }); if (RESERVED_EVENT_NAMES.includes(name)) { throw Error(`Analytics event name ${name} is reserved.`); } Object.keys(parameters).forEach(key => { if (RESERVED_EVENT_PARAMETERS.includes(key)) { throw Error(`Parameter name ${key} is reserved.`); } if (typeof parameters[key] === 'object') { throw Error(`Parameter ${key} is an object. Only string and integer allowed.`); } }); if (parameters.length > MAX_PARAMETERS_LENGTH) { throw Error(`Too many parameters. Maximum allowed is ${MAX_PARAMETERS_LENGTH}.`); } } } /** * Get a language code. */ function getLanguage() { return (process.env.LANG || // Default Unix env variable. process.env.LC_CTYPE || // For C libraries. Sometimes the above isn't set. process.env.LANGSPEC || // For Windows, sometimes this will be set (not always). getWindowsLanguageCode() || '??'); } /** * Attempt to get the Windows Language Code string. */ function getWindowsLanguageCode() { if (!os.platform().startsWith('win')) { return undefined; } try { // This is true on Windows XP, 7, 8 and 10 AFAIK. Would return empty string or fail if it // doesn't work. return execSync('wmic.exe os get locale').toString().trim(); } catch (err) {;} return undefined; } /** * Build a fake User Agent string. This gets sent to Analytics so it shows the proper OS version. */ function buildUserAgentString() { const cpus = os.cpus(); const cpuModel = cpus.length > 1 ? cpus[0].model : 'NA'; return `${os.platform()}/${os.release()}/${cpuModel}`; } // export singleton instance module.exports = new AnalyticsCollector();;