/** * MIT License * * Copyright (c) 2020-present, Elastic NV * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in * all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. * */ import { red, green, yellow, cyan, bold, grey } from 'kleur/colors'; import os from 'os'; import { resolve, join, dirname } from 'path'; import fs from 'fs'; import { lstat, readdir } from 'fs/promises'; import { performance } from 'perf_hooks'; import sourceMapSupport from 'source-map-support'; import { HooksArgs, HooksCallback, NetworkConditions, Location, ThrottlingOptions, ProxySettings, } from './common_types'; import micromatch from 'micromatch'; import { ProxyAgent, setGlobalDispatcher } from 'undici'; import { EnvHttpProxyAgent } from 'undici'; const SEPARATOR = '\n'; export function noop() {} export function indent(lines: string, tab = ' ') { return lines.replace(/^/gm, tab); } /** * Disable unicode symbols for windows, the underlying * FS stream has a known issue in windows */ const NO_UTF8_SUPPORT = process.platform === 'win32'; export const symbols = { warning: yellow(NO_UTF8_SUPPORT ? '!' : '⚠'), pending: yellow(NO_UTF8_SUPPORT ? '!' : '⚠'), skipped: cyan('-'), progress: cyan('>'), succeeded: green(NO_UTF8_SUPPORT ? 'ok' : '✓'), failed: red(NO_UTF8_SUPPORT ? 'x' : '✖'), }; export function generateUniqueId() { return `${Date.now() + Math.floor(Math.random() * 1e13)}`; } export function generateTempPath() { return join(os.tmpdir(), `synthetics-${generateUniqueId()}`); } /** * Get Monotonically increasing time in seconds since * an arbitrary point in the past. * * We internally use the monotonically increasing clock timing * similar to the chrome devtools protocol network events for * journey,step start/end fields to make querying in the UI easier */ export function monotonicTimeInSeconds() { const hrTime = process.hrtime(); // [seconds, nanoseconds] return hrTime[0] * 1 + hrTime[1] / 1e9; } /** * Converts the trace events timestamp field from microsecond * resolution to monotonic seconds timestamp similar to other event types (journey, step, etc) * Reference - https://github.com/samccone/chrome-trace-event/blob/d45bc8af3b5c53a3adfa2c5fc107b4fae054f579/lib/trace-event.ts#L21-L22 * * Tested and verified on both Darwin and Linux */ export function microSecsToSeconds(ts: number) { return ts / 1e6; } /** * Timestamp at which the current node process began. */ export const processStart = performance.timeOrigin; export function getTimestamp() { return (processStart + now()) * 1000; } /** * Relative current time from the start of the current node process */ export function now() { return performance.now(); } /** * Execute all the hooks callbacks in parallel using Promise.all */ export async function runParallel( callbacks: Array, args: HooksArgs ) { const promises = callbacks.map(cb => cb(args)); return await Promise.all(promises); } export function isDepInstalled(dep) { try { return require.resolve(dep); } catch (e) { return false; } } export function isDirectory(path) { return fs.existsSync(path) && fs.statSync(path).isDirectory(); } export function isFile(filePath) { return fs.existsSync(filePath) && fs.statSync(filePath).isFile(); } /** * Traverse the directory tree up from the cwd until we find * package.json file to check if the user is invoking our script * from an NPM project. */ export function findPkgJsonByTraversing(resolvePath, cwd) { const packageJSON = resolve(resolvePath, 'package.json'); if (isFile(packageJSON)) { return packageJSON; } const parentDirectory = dirname(resolvePath); /** * We are in the system root and package.json does not exist */ if (resolvePath === parentDirectory) { throw red( `Could not find package.json file in: "${cwd}"\n` + `It is recommended to run the agent in an NPM project.\n` + `You can create one by running "npm init -y" in the project folder.` ); } return findPkgJsonByTraversing(parentDirectory, cwd); } /** * Modified version of `totalist` package that handles the symlink issue * and avoids infinite recursion * * Based on code from totalist! * https://github.com/lukeed/totalist/blob/44379974e535afe9c38e8d643dd64c59101a14b9/src/async.js#L8 */ export async function totalist( dir: string, callback: (relPath: string, absPath: string) => any, pre = '' ) { dir = resolve('.', dir); await readdir(dir).then(arr => { return Promise.all( arr.map(str => { const abs = join(dir, str); return lstat(abs).then(stats => stats.isDirectory() ? totalist(abs, callback, join(pre, str)) : callback(join(pre, str), abs) ); }) ); }); } /** * Find index of Playwright specific Error logs that is thrown * as part of the custom error message/stack */ export function findPWLogsIndexes(msgOrStack: string): [number, number] { let startIndex = 0; let endIndex = 0; if (!msgOrStack) { return [startIndex, endIndex]; } const lines = String(msgOrStack).split(SEPARATOR); const logStart = /[=]{3,} logs [=]{3,}/; const logEnd = /[=]{10,}/; lines.forEach((line, index) => { if (logStart.test(line)) { startIndex = index; } else if (logEnd.test(line)) { endIndex = index; } }); return [startIndex, endIndex]; } export function rewriteErrorMessage(message: string, start: number) { if (start === 0) { return message; } return message.split(SEPARATOR).slice(0, start).join(SEPARATOR); } export function rewriteErrorStack(stack: string, indexes: [number, number]) { const [start, end] = indexes; /** * Do not rewrite if its not a playwright error */ if (start === 0 && end === 0) { return stack; } const linesToKeep = start + 3; if (start > 0 && linesToKeep < end) { const lines = stack.split(SEPARATOR); return lines .slice(0, linesToKeep) .concat(...lines.slice(end)) .join(SEPARATOR); } return stack; } const cwd = process.cwd(); /** * All the settings that are related to the Synthetics is stored * under this directory * Examples: Screenshots, Project setup */ export const SYNTHETICS_PATH = join(cwd, '.synthetics'); /** * Synthetics cache path that is based on the process id to make sure * each process does not modify the caching layer used by other process * once we move to executing journeys in parallel */ export const CACHE_PATH = join(SYNTHETICS_PATH, process.pid.toString()); export function getDurationInUs(duration: number) { return Math.trunc(duration * 1e6); } export function megabitsToBytes(megabytes: number) { return (megabytes * 1024 * 1024) / 8; } export const DEFAULT_THROTTLING_OPTIONS: ThrottlingOptions = { download: 5, upload: 3, latency: 20, }; /** * Transforms the CLI throttling arguments in to format * expected by Chrome devtools protocol NetworkConditions */ export function getNetworkConditions( throttlingOpts: ThrottlingOptions ): NetworkConditions { return { downloadThroughput: megabitsToBytes(throttlingOpts.download), uploadThroughput: megabitsToBytes(throttlingOpts.upload), // Devtools CDP expects format to be in bytes/second latency: throttlingOpts.latency, // milliseconds, offline: false, }; } export const THROTTLING_WARNING_MSG = `Throttling may not be active when the tests run - see https://github.com/elastic/synthetics/blob/main/docs/throttling.md for more details`; const dstackTraceLimit = 10; // Uses the V8 Stacktrace API to get the function location // information - https://v8.dev/docs/stack-trace-api#customizing-stack-traces export function wrapFnWithLocation( func: (location: Location, ...args: A) => R ): (...args: A) => R { return (...args) => { const _prepareStackTrace = Error.prepareStackTrace; Error.prepareStackTrace = (_, stackFrames) => { // Deafult CallSite would not map to the original transpiled source // correctly, So we use source-map-support to map the CallSite to the // original source from our cached source map const frame: NodeJS.CallSite = sourceMapSupport.wrapCallSite( stackFrames[1] ); return { file: frame.getFileName(), line: frame.getLineNumber(), column: frame.getColumnNumber(), }; }; Error.stackTraceLimit = 2; const obj: { stack: Location } = {} as any; Error.captureStackTrace(obj); const location = obj.stack; Error.stackTraceLimit = dstackTraceLimit; Error.prepareStackTrace = _prepareStackTrace; return func(location, ...args); }; } // Safely parse ND JSON (Newline delimited JSON) chunks export function safeNDJSONParse(data: string | string[]) { // data may not be at proper newline boundaries, so we make sure everything is split // on proper newlines const chunks = Array.isArray(data) ? data : [data]; const lines = chunks.join('\n').split(/\r?\n/); return lines .filter(l => l.match(/\S/)) // remove blank lines .map(line => { try { return JSON.parse(line); } catch (e) { throw `Error ${e} could not parse data '${line}'`; } }); } // Console helpers export function write(message: string, live?: boolean) { process.stderr.write(message + (live ? '\r' : '\n')); } export function progress(message: string) { write(cyan(bold(`${symbols.progress} ${message}`))); } export async function liveProgress(promise: Promise, message: string) { const start = now(); const interval = setInterval(() => { apiProgress(`${message} (${Math.trunc(now() - start)}ms)`, true); }, 500); promise.finally(() => clearInterval(interval)); const result = await promise; apiProgress(`${message} (${Math.trunc(now() - start)}ms)`); return result; } export function apiProgress(message: string, live = false) { write(grey(`> ${message}`), live); } export function error(message: string) { write(red(message)); } export function done(message: string) { write(bold(green(`${symbols['succeeded']} ${message}`))); } export function warn(message: string) { write(bold(yellow(`${symbols['warning']} ${message}`))); } export function removeTrailingSlash(url = '') { return url.replace(/\/+$/, ''); } export function getMonitorManagementURL(url: string) { return removeTrailingSlash(url) + '/app/synthetics/monitors'; } export function getProjectApiKeyURL(url: string) { return removeTrailingSlash(url) + '/app/synthetics/settings/api-keys'; } /** * Matches tests based on the provided args. Proitize tags over match * - tags pattern that matches only tags * - match pattern that matches both name and tags */ export function isMatch( tags: Array, name: string, tagsPattern?: Array, matchPattern?: string ) { if (tagsPattern) { return tagsMatch(tags, tagsPattern); } if (matchPattern) { return ( micromatch.isMatch(name, matchPattern) || tagsMatch(tags, matchPattern) ); } return true; } export function tagsMatch(tags, pattern) { const matchess = micromatch(tags || ['*'], pattern); return matchess.length > 0; } export function setGlobalProxy(opts: ProxySettings) { // Create proxy agent if options exist, if not attempt to load env proxy const proxyAgent = opts.uri ? new ProxyAgent({ uri: opts.uri, token: opts.token, proxyTls: { ca: opts.ca ?? null, cert: opts.cert ?? null, rejectUnauthorized: !opts.noVerify, }, }) : new EnvHttpProxyAgent(); setGlobalDispatcher(proxyAgent); }