UNPKG

artillery

Version:

Cloud-scale load testing. https://www.artillery.io

710 lines (617 loc) 21.6 kB
const path = require('node:path'); const fs = require('node:fs'); const A = require('async'); const { isBuiltin } = require('node:module'); const walkSync = require('walk-sync'); const debug = require('debug')('bom'); const _ = require('lodash'); const esbuild = require('esbuild-wasm'); const BUILTIN_PLUGINS = require('./plugins').getAllPluginNames(); const BUILTIN_ENGINES = require('./plugins').getOfficialEngines(); const Table = require('cli-table3'); const { resolveConfigTemplates } = require('../../../../util'); const prepareTestExecutionPlan = require('../../../../lib/util/prepare-test-execution-plan'); const { readScript, parseScript } = require('../../../../util'); // NOTE: Code below presumes that all paths are absolute //Tests in Fargate run on ubuntu, which uses posix paths //This function converts a path to posix path, in case the original path was not posix (e.g. windows runs) function _convertToPosixPath(p) { return p.split(path.sep).join(path.posix.sep); } // NOTE: absoluteScriptPath here is actually the absolute path to the config file function createBOM(absoluteScriptPath, extraFiles, opts, callback) { A.waterfall( [ A.constant(absoluteScriptPath), async (scriptPath) => { let scriptData; if (scriptPath.toLowerCase().endsWith('.ts')) { scriptData = await prepareTestExecutionPlan( [scriptPath], opts.flags, [] ); scriptData.config.processor = scriptPath; } else { const data = await readScript(scriptPath); scriptData = await parseScript(data); } return scriptData; }, (scriptData, next) => { return next(null, { opts: { scriptData, absoluteScriptPath, flags: opts.flags, scenarioPath: opts.scenarioPath // Absolute path to the file that holds scenarios }, localFilePaths: [absoluteScriptPath], npmModules: [] }); }, applyScriptChanges, getPlugins, getCustomEngines, getCustomJsDependencies, getVariableDataFiles, getFileUploadPluginFiles, getExtraFiles, getDotEnv, expandDirectories ], (err, context) => { if (err) { return callback(err, null); } context.localFilePaths = context.localFilePaths.concat(extraFiles); // TODO: Entries in localFilePaths may be directories // How many entries do we have here? If we have only one entry, the string itself // will be the common prefix, meaning that when we substring() on it later, we'll // get an empty string, ending up with a manifest like: // { files: // [ { orig: '/Users/h/tmp/artillery/hello.yaml', noPrefix: '' } ], // modules: [] } // let prefix = ''; if (context.localFilePaths.length === 1) { prefix = context.localFilePaths[0].substring( 0, context.localFilePaths[0].length - path.basename(context.localFilePaths[0]).length ); // This may still be an empty string if the script path is just 'hello.yml': prefix = prefix.length === 0 ? context.localFilePaths[0] : prefix; } else { prefix = commonPrefix(context.localFilePaths); } prefix = _convertToPosixPath(prefix); debug('prefix', prefix); // // include package.json / package-lock.json / yarn.lock // let packageDescriptionFiles = ['.npmrc']; if (opts.packageJsonPath) { packageDescriptionFiles.push(opts.packageJsonPath); } else { packageDescriptionFiles = packageDescriptionFiles.concat([ 'package.json', 'package-lock.json', 'yarn.lock' ]); } const dependencyFiles = packageDescriptionFiles.map((s) => path.join(prefix, s) ); debug(dependencyFiles); dependencyFiles.forEach((p) => { try { if (fs.statSync(p)) { context.localFilePaths.push(p); } } catch (_ignoredErr) {} }); const files = context.localFilePaths.map((p) => { return { orig: p, noPrefix: p.substring(prefix.length, p.length), origPosix: _convertToPosixPath(p), noPrefixPosix: _convertToPosixPath(p).substring( prefix.length, p.length ) }; }); const pkgPath = _.find(files, (f) => { return f.noPrefix === 'package.json'; }); if (pkgPath) { const pkg = JSON.parse(fs.readFileSync(pkgPath.orig, 'utf8')); const pkgDeps = [].concat( Object.keys(pkg.dependencies || {}), Object.keys(pkg.devDependencies || {}) ); context.pkgDeps = pkgDeps; context.npmModules = _.uniq(context.npmModules.concat(pkgDeps)).sort(); } else { context.pkgDeps = []; } const modules = _.uniq(context.npmModules).filter( (m) => m !== 'artillery' && m !== 'playwright' && !m.startsWith('@playwright/') ); const moduleVersions = context.moduleVersions || {}; const unresolvedImports = context.unresolvedImports || []; const declaredDeps = new Set(context.pkgDeps); const externals = []; for (const m of modules) { if (!declaredDeps.has(m)) { externals.push({ name: m, reason: 'not-in-package-json' }); } } for (const u of unresolvedImports) { externals.push({ name: u.name, reason: u.reason }); } return callback(null, { files: _.uniqWith(files, _.isEqual), modules, pkgDeps: context.pkgDeps, fullyResolvedConfig: context.opts.scriptData.config, moduleVersions, externals }); } ); } function applyScriptChanges(context, next) { resolveConfigTemplates( context.opts.scriptData, context.opts.flags, context.opts.absoluteScriptPath, context.opts.scenarioPath ).then((resolvedConfig) => { context.opts.scriptData = resolvedConfig; return next(null, context); }); } function getPlugins(context, next) { const environmentPlugins = _.reduce( _.get(context, 'opts.scriptData.config.environments', {}), function getEnvironmentPlugins(acc, envSpec, _envName) { acc = acc.concat(Object.keys(envSpec.plugins || [])); return acc; }, [] ); const pluginNames = Object.keys( _.get(context, 'opts.scriptData.config.plugins', {}) ).concat(environmentPlugins); const pluginPackages = _.uniq( pluginNames .filter((p) => BUILTIN_PLUGINS.indexOf(p) === -1) .map((p) => `artillery-plugin-${p}`) ); debug(pluginPackages); context.npmModules = context.npmModules.concat(pluginPackages); return next(null, context); } function getCustomEngines(context, next) { const environmentEngines = _.reduce( _.get(context, 'opts.scriptData.config.environments', {}), function getEnvironmentEngines(acc, envSpec, _envName) { acc = acc.concat(Object.keys(envSpec.engines || [])); return acc; }, [] ); const engineNames = Object.keys( _.get(context, 'opts.scriptData.config.engines', {}) ).concat(environmentEngines); const enginePackages = _.uniq( engineNames .filter((p) => BUILTIN_ENGINES.indexOf(p) === -1) .map((p) => `artillery-engine-${p}`) ); context.npmModules = context.npmModules.concat(enginePackages); return next(null, context); } // async waterfall passes ONE arg to async functions and reads the result via // the returned promise — no `next` callback. Throws propagate as errors. async function getCustomJsDependencies(context) { const scriptPath = context.opts.absoluteScriptPath; const isTypeScriptEntry = scriptPath.toLowerCase().endsWith('.ts'); const resolveRoot = path.dirname(scriptPath); const entries = []; // .ts script entry: trace the script itself (handles imports in script body // when prepareTestExecutionPlan ingests TypeScript modules). if (isTypeScriptEntry) { entries.push(scriptPath); } // Pick the user-declared processor path. If prepareTestExecutionPlan // bundled a .ts processor to dist/ and stashed __originalProcessor, use that // — we want to trace the original source, not the already-bundled output. const originalProcessor = context.opts.scriptData.__originalProcessor; const declaredProcessor = context.opts.scriptData.config?.processor; let processorEntry = null; if (originalProcessor) { processorEntry = originalProcessor; } else if (declaredProcessor) { const resolved = path.resolve(resolveRoot, declaredProcessor); // bom.js applyScriptChanges sets config.processor = scriptPath for .ts // entries (preserving older dep-tree behaviour). Avoid double-tracing. if (resolved !== scriptPath) { processorEntry = resolved; } } if (processorEntry && !entries.includes(processorEntry)) { entries.push(processorEntry); } context.moduleVersions = context.moduleVersions || {}; context.unresolvedImports = context.unresolvedImports || []; if (entries.length === 0) { debug('no custom JS dependencies'); return context; } const traceResult = await traceDependencies(entries, resolveRoot); context.localFilePaths = _.uniq( context.localFilePaths.concat(traceResult.localFiles) ); context.npmModules = context.npmModules.concat(traceResult.npmPackages); for (const pkg of traceResult.npmPackages) { if (context.moduleVersions[pkg]) continue; const version = resolvePackageVersion(pkg, resolveRoot); if (version) context.moduleVersions[pkg] = version; } context.unresolvedImports = context.unresolvedImports.concat( traceResult.unresolved ); debug('got custom JS dependencies via esbuild'); return context; } // esbuild-wasm's initialize() can only be called once per process. Memoize // the promise so concurrent or repeat trace calls share a single init. // This is the only call site for initialize() in the codebase // (prepare-test-execution-plan.js uses buildSync, which doesn't need init), // so we don't need to defend against external "already initialized" errors. let esbuildInitPromise = null; function ensureEsbuildInitialized(esbuild) { if (!esbuildInitPromise) { esbuildInitPromise = esbuild.initialize({}); } return esbuildInitPromise; } async function traceDependencies(entries, resolveRoot) { const unresolved = []; const recoverPlugin = { name: 'artillery-recover-unresolved', setup(build) { build.onResolve({ filter: /^\.{1,2}\// }, (args) => { const candidate = path.resolve(args.resolveDir, args.path); const exts = [ '', '.js', '.mjs', '.cjs', '.ts', '.tsx', '.json', '/index.js', '/index.mjs', '/index.cjs', '/index.ts', '/index.tsx' ]; for (const ext of exts) { try { if (fs.statSync(candidate + ext).isFile()) { return null; } } catch (_e) {} } unresolved.push({ name: args.path, reason: 'unresolved-relative', importer: args.importer }); return { path: args.path, external: true }; }); } }; // esbuild-wasm doesn't support plugins via buildSync. Need the async API, // which in turn requires a one-time initialize(). prepareTestExecutionPlan // uses buildSync directly without initialize — that path stays sync and // doesn't conflict with this one (initialize is idempotent within a // single process; buildSync works either way). await ensureEsbuildInitialized(esbuild); const result = await esbuild.build({ entryPoints: entries, bundle: true, write: false, metafile: true, packages: 'external', platform: 'node', format: 'cjs', logLevel: 'silent', absWorkingDir: resolveRoot, plugins: [recoverPlugin] }); const localFiles = new Set(); const npmPackages = new Set(); for (const inputPath of Object.keys(result.metafile.inputs)) { const input = result.metafile.inputs[inputPath]; const absInputPath = path.isAbsolute(inputPath) ? inputPath : path.resolve(resolveRoot, inputPath); if (!absInputPath.includes(`${path.sep}node_modules${path.sep}`)) { localFiles.add(absInputPath); } for (const imp of input.imports || []) { if (!imp.external) continue; if (isBuiltin(imp.path)) continue; const pkgName = extractPackageName(imp.path); if (pkgName) npmPackages.add(pkgName); } } return { localFiles: Array.from(localFiles), npmPackages: Array.from(npmPackages), unresolved }; } function extractPackageName(spec) { if (spec.startsWith('@')) { const parts = spec.split('/'); return parts.length >= 2 ? `${parts[0]}/${parts[1]}` : null; } return spec.split('/')[0]; } function resolvePackageVersion(pkgName, resolveRoot) { try { const pkgJsonPath = require.resolve(`${pkgName}/package.json`, { paths: [resolveRoot] }); const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8')); return pkg.version || null; } catch (_err) { return null; } } function getVariableDataFiles(context, next) { // NOTE: assuming that context.opts.scriptData contains both the config and // the scenarios section here. // Iterate over environments function resolvePayloadPaths(obj) { const result = []; if (obj.payload) { // When using a separate config file, resolve paths relative to the scenario file // Otherwise, resolve relative to the config file const baseDir = context.opts.scenarioPath ? path.dirname(context.opts.scenarioPath) : path.dirname(context.opts.absoluteScriptPath); if (_.isArray(obj.payload)) { obj.payload.forEach((payloadSpec) => { result.push(path.resolve(baseDir, payloadSpec.path)); }); } else if (_.isObject(obj.payload)) { // isObject returns true for arrays, so this branch must come second result.push(path.resolve(baseDir, obj.payload.path)); } } return result; } context.localFilePaths = context.localFilePaths.concat( resolvePayloadPaths(context.opts.scriptData.config) ); context.opts.scriptData.config.environments = context.opts.scriptData.config.environments || {}; Object.keys(context.opts.scriptData.config.environments).forEach( (envName) => { const envSpec = context.opts.scriptData.config.environments[envName]; context.localFilePaths = context.localFilePaths.concat( resolvePayloadPaths(envSpec) ); } ); return next(null, context); } function getFileUploadPluginFiles(context, next) { if (context.opts.scriptData.config?.plugins?.['http-file-uploads']) { // Append filePaths array if it's there: if (context.opts.scriptData.config.plugins['http-file-uploads'].filePaths) { // When using a separate config file, resolve paths relative to the scenario file // Otherwise, resolve relative to the config file const baseDir = context.opts.scenarioPath ? path.dirname(context.opts.scenarioPath) : path.dirname(context.opts.absoluteScriptPath); const absPaths = context.opts.scriptData.config.plugins[ 'http-file-uploads' ].filePaths.map((p) => { return path.resolve(baseDir, p); }); context.localFilePaths = context.localFilePaths.concat(absPaths); } return next(null, context); } else { return next(null, context); } } function getExtraFiles(context, next) { if (context.opts.scriptData.config?.includeFiles) { // When using a separate config file, resolve paths relative to the scenario file // Otherwise, resolve relative to the config file const baseDir = context.opts.scenarioPath ? path.dirname(context.opts.scenarioPath) : path.dirname(context.opts.absoluteScriptPath); const absPaths = _.map(context.opts.scriptData.config.includeFiles, (p) => { const includePath = path.resolve(baseDir, p); debug('includeFile:', includePath); return includePath; }); context.localFilePaths = context.localFilePaths.concat(absPaths); return next(null, context); } else { return next(null, context); } } function getDotEnv(context, next) { const flags = context.opts.flags; if (!flags.dotenv || flags.platform === 'aws:ecs') { return next(null, context); } const dotEnvPath = path.resolve(process.cwd(), flags.dotenv); try { if (fs.statSync(dotEnvPath)) { context.localFilePaths.push(dotEnvPath); } } catch (_ignoredErr) { console.log(`WARNING: could not find dotenv file: ${flags.dotenv}`); } return next(null, context); } function expandDirectories(context, next) { // This can potentially lead to VERY unexpected behaviour, when used // without due care with the file upload plugin (if filePaths is pointed at // a directory that contains files OTHER than those to be used with the // plugin) // // TODO: Warn if there are too many files in the directory // TODO: Only allow specific filenames or globs, not directories debug(context.localFilePaths); // FIXME: Don't need to scan twice: const dirs = context.localFilePaths.filter((p) => { let result = false; try { result = fs.statSync(p).isDirectory(); } catch (_fsErr) {} return result; }); // Remove directories from the list: context.localFilePaths = context.localFilePaths.filter((p) => { let result = true; try { result = !fs.statSync(p).isDirectory(); } catch (_fsErr) {} return result; }); debug('Dirs to expand'); debug(dirs); dirs.forEach((d) => { const entries = walkSync.entries(d, { directories: false }); debug(entries); context.localFilePaths = context.localFilePaths.concat( entries.map((e) => { return path.resolve(d, e.relativePath); }) ); }); return next(null, context); } function commonPrefix(paths, separator) { if ( !paths || paths.length === 0 || paths.filter((s) => typeof s !== 'string').length > 0 ) { return ''; } if (paths.includes('/')) { return '/'; } const sep = separator ? separator : path.sep; const splitPaths = paths.map((p) => p.split(sep)); const shortestPath = splitPaths.reduce((a, b) => { return a.length < b.length ? a : b; }, splitPaths[0]); let furthestIndex = shortestPath.length; for (const p of splitPaths) { for (let i = 0; i < furthestIndex; i++) { if (p[i] !== shortestPath[i]) { furthestIndex = i; break; } } } const joined = shortestPath.slice(0, furthestIndex).join(sep); if (joined.length > 0) { // Check if joined path already ends with separator which // will happen when input is a root drive on Windows, e.g. "C:\" return joined.endsWith(sep) ? joined : joined + sep; } else { return ''; } } function prettyPrint(manifest) { artillery.logger({ showTimestamp: true }).log('Test bundle prepared...'); artillery.log('Test bundle contents:'); const t = new Table({ head: ['Name', 'Type', 'Notes'] }); for (const f of manifest.files) { t.push([f.noPrefix, 'file']); } for (const m of manifest.modules) { const version = manifest.moduleVersions?.[m]; const notes = []; if (manifest.pkgDeps.indexOf(m) === -1) notes.push('not in package.json'); if (version) notes.push(`v${version}`); t.push([m, 'package', notes.join(' · ')]); } artillery.log(t.toString()); const unresolvedExternals = (manifest.externals || []).filter( (e) => e.reason !== 'not-in-package-json' ); if (unresolvedExternals.length > 0) { artillery.log('Unresolved imports:'); const u = new Table({ head: ['Name', 'Reason'] }); for (const e of unresolvedExternals) { u.push([e.name, e.reason]); } artillery.log(u.toString()); } artillery.log(); } function enrichPackageJson(content, moduleVersions) { const pkg = typeof content === 'string' ? JSON.parse(content) : content; const filterBundled = (deps) => { if (!deps) return deps; const filtered = {}; for (const [name, version] of Object.entries(deps)) { if ( name !== 'artillery' && name !== 'playwright' && !name.startsWith('@playwright/') ) { filtered[name] = version; } } return filtered; }; pkg.dependencies = filterBundled(pkg.dependencies) || {}; pkg.devDependencies = filterBundled(pkg.devDependencies); // Add detected modules that aren't already declared. Pin to exact version // so the remote runner installs what we observed locally. if (moduleVersions) { for (const [name, version] of Object.entries(moduleVersions)) { if (!version) continue; if ( name === 'artillery' || name === 'playwright' || name.startsWith('@playwright/') ) continue; const inDeps = pkg.dependencies && pkg.dependencies[name]; const inDev = pkg.devDependencies && pkg.devDependencies[name]; if (!inDeps && !inDev) { pkg.dependencies[name] = version; } } } return JSON.stringify(pkg, null, 2); } module.exports = { createBOM, commonPrefix, prettyPrint, applyScriptChanges, enrichPackageJson };