UNPKG

dd-trace

Version:

Datadog APM tracing client for JavaScript

452 lines (394 loc) 15.6 kB
'use strict' const fs = require('node:fs') const path = require('node:path') const log = require('../log') const { getNumFromKnownTests } = require('../plugins/util/test') const { parseKnownTestsResponse } = require('./early-flake-detection/get-known-tests') const { logSkippableSuitesResponse, parseSkippableSuitesResponse, } = require('./intelligent-test-runner/get-skippable-suites') const { parseLibraryConfigurationResponse } = require('./requests/get-library-configuration') const { getNumFromTestManagementTests, parseTestManagementTestsResponse, } = require('./test-management/get-test-management-tests') const { incrementCountMetric, distributionMetric, TELEMETRY_GIT_REQUESTS_SETTINGS_RESPONSE, TELEMETRY_KNOWN_TESTS_RESPONSE_TESTS, TELEMETRY_KNOWN_TESTS_RESPONSE_BYTES, TELEMETRY_ITR_SKIPPABLE_TESTS_RESPONSE_SUITES, TELEMETRY_ITR_SKIPPABLE_TESTS_RESPONSE_TESTS, TELEMETRY_ITR_SKIPPABLE_TESTS_RESPONSE_BYTES, TELEMETRY_TEST_MANAGEMENT_TESTS_RESPONSE_TESTS, TELEMETRY_TEST_MANAGEMENT_TESTS_RESPONSE_BYTES, } = require('./telemetry') const CACHE_MISS = Symbol('test optimization http cache miss') const PLAN_FOLDER = '.testoptimization' const MANIFEST_FILE_NAME = 'manifest.txt' const SUPPORTED_MANIFEST_VERSION = '1' const CACHE_FOLDER_NAME = 'cache' const HTTP_CACHE_FOLDER_NAME = 'http' const ENV_MANIFEST_FILE = 'TEST_OPTIMIZATION_MANIFEST_FILE' const ENV_MANIFEST_FILE_ALIAS = 'DD_TEST_OPTIMIZATION_MANIFEST_FILE' const ENV_RUNFILES_DIR = 'RUNFILES_DIR' const ENV_RUNFILES_MANIFEST_FILE = 'RUNFILES_MANIFEST_FILE' const ENV_TEST_SRCDIR = 'TEST_SRCDIR' const SETTINGS_FILE_NAME = 'settings.json' const KNOWN_TESTS_FILE_NAME = 'known_tests.json' const SKIPPABLE_TESTS_FILE_NAME = 'skippable_tests.json' const TEST_MANAGEMENT_FILE_NAME = 'test_management.json' const RUNFILES_MANIFEST_SEPARATOR = ' ' const DEFAULT_VALIDATION_MAX_FILE_BYTES = 1024 * 1024 const DEFAULT_VALIDATION_MAX_ENTRIES = 100_000 const DEFAULT_VALIDATION_MAX_NESTING_DEPTH = 32 const DEFAULT_VALIDATION_MAX_STRING_BYTES = 4096 function parseManifestVersion (content) { // Supported just the number version or 'version=x' const version = content.replace(/^\uFEFF/, '').trim() const match = version.match(/^version=(.+)$/) return match ? match[1].trim() : version } class TestOptimizationHttpCache { constructor ({ cwd = process.cwd(), env, validationManifestPath, maxFileBytes = DEFAULT_VALIDATION_MAX_FILE_BYTES, } = {}) { this._cwd = cwd // This cache intentionally consumes env vars that are not tracer config keys. (??) // eslint-disable-next-line eslint-rules/eslint-process-env this._env = env ?? process.env this._validationManifestPath = validationManifestPath this._maxFileBytes = maxFileBytes this._lastError = undefined this._manifestPath = validationManifestPath ? this._resolveValidationManifestPath(validationManifestPath) : this._resolveManifestPath() this._testOptimizationPath = undefined this._httpCachePath = undefined this._available = false this._buildReader() } isAvailable () { return this._available } getLastError () { return this._lastError } readSettings () { const payload = this._readFile(SETTINGS_FILE_NAME) if (payload === CACHE_MISS) { this._disable() return CACHE_MISS } try { const settings = parseLibraryConfigurationResponse( this._parsePayload(payload, SETTINGS_FILE_NAME), undefined, { validateRequiredFields: true, validationMode: Boolean(this._validationManifestPath) } ) incrementCountMetric(TELEMETRY_GIT_REQUESTS_SETTINGS_RESPONSE, settings) return settings } catch (err) { this._logInvalidCacheFile(SETTINGS_FILE_NAME, err) this._disable() return CACHE_MISS } } readKnownTests () { const payload = this._readFile(KNOWN_TESTS_FILE_NAME) if (payload === CACHE_MISS) return CACHE_MISS try { const knownTests = parseKnownTestsResponse( this._parsePayload(payload, KNOWN_TESTS_FILE_NAME), { validateRequiredFields: true, validationMode: Boolean(this._validationManifestPath) } ) distributionMetric(TELEMETRY_KNOWN_TESTS_RESPONSE_TESTS, {}, getNumFromKnownTests(knownTests)) distributionMetric(TELEMETRY_KNOWN_TESTS_RESPONSE_BYTES, {}, payload.length) return knownTests } catch (err) { this._logInvalidCacheFile(KNOWN_TESTS_FILE_NAME, err) return CACHE_MISS } } readSkippableSuites (options = {}) { const payload = this._readFile(SKIPPABLE_TESTS_FILE_NAME) if (payload === CACHE_MISS) return CACHE_MISS try { const boundedPayload = this._parsePayload(payload, SKIPPABLE_TESTS_FILE_NAME) const parsedResponse = typeof boundedPayload === 'string' ? JSON.parse(boundedPayload) : boundedPayload const result = parseSkippableSuitesResponse(parsedResponse, { ...options, validateRequiredFields: true, validationMode: Boolean(this._validationManifestPath), }) const testLevel = options.testLevel || 'suite' logSkippableSuitesResponse(result, testLevel) const skippableItems = parsedResponse.data.filter(({ type }) => type === testLevel) incrementCountMetric( testLevel === 'test' ? TELEMETRY_ITR_SKIPPABLE_TESTS_RESPONSE_TESTS : TELEMETRY_ITR_SKIPPABLE_TESTS_RESPONSE_SUITES, {}, skippableItems.length ) distributionMetric(TELEMETRY_ITR_SKIPPABLE_TESTS_RESPONSE_BYTES, {}, payload.length) return result } catch (err) { this._logInvalidCacheFile(SKIPPABLE_TESTS_FILE_NAME, err) return CACHE_MISS } } hasValidSkippableSuites (options = {}) { const payload = this._readFile(SKIPPABLE_TESTS_FILE_NAME) if (payload === CACHE_MISS) return false try { parseSkippableSuitesResponse( this._parsePayload(payload, SKIPPABLE_TESTS_FILE_NAME), { ...options, validateRequiredFields: true, validationMode: Boolean(this._validationManifestPath) } ) return true } catch { return false } } readTestManagementTests () { const payload = this._readFile(TEST_MANAGEMENT_FILE_NAME) if (payload === CACHE_MISS) return CACHE_MISS try { const testManagementTests = parseTestManagementTestsResponse( this._parsePayload(payload, TEST_MANAGEMENT_FILE_NAME), { validateRequiredFields: true, validationMode: Boolean(this._validationManifestPath) } ) distributionMetric( TELEMETRY_TEST_MANAGEMENT_TESTS_RESPONSE_TESTS, {}, getNumFromTestManagementTests(testManagementTests) ) distributionMetric(TELEMETRY_TEST_MANAGEMENT_TESTS_RESPONSE_BYTES, {}, payload.length) return testManagementTests } catch (err) { this._logInvalidCacheFile(TEST_MANAGEMENT_FILE_NAME, err) return CACHE_MISS } } _buildReader () { if (!this._manifestPath) { if (this._validationManifestPath && !this._lastError) { this._setError('Offline Test Optimization validation manifest was not found.') } log.debug('Test Optimization HTTP cache manifest not found') return } const version = this._readManifestVersion(this._manifestPath) if (version !== SUPPORTED_MANIFEST_VERSION) { if (this._validationManifestPath) { this._setError(`Unsupported offline Test Optimization validation manifest version: ${version || 'missing'}.`) } log.debug('Unsupported Test Optimization HTTP cache manifest version %j at %s', version, this._manifestPath) return } this._testOptimizationPath = path.dirname(this._manifestPath) this._httpCachePath = path.join( this._testOptimizationPath, CACHE_FOLDER_NAME, HTTP_CACHE_FOLDER_NAME ) const settingsPath = path.join(this._httpCachePath, SETTINGS_FILE_NAME) if (!fs.existsSync(settingsPath)) { if (this._validationManifestPath) { this._setError('Offline Test Optimization validation settings fixture is missing.') } log.debug('Test Optimization HTTP cache settings file not found at %s', settingsPath) return } log.debug('Test Optimization HTTP cache found at %s', this._httpCachePath) this._available = true } _resolveManifestPath () { const localManifest = path.join(this._cwd, PLAN_FOLDER, MANIFEST_FILE_NAME) if (fs.existsSync(localManifest)) { return localManifest } const manifestFile = this._env[ENV_MANIFEST_FILE] || this._env[ENV_MANIFEST_FILE_ALIAS] if (!manifestFile) return const resolvedManifestPath = this._resolveRunfilePath(manifestFile) if (fs.existsSync(resolvedManifestPath)) { return resolvedManifestPath } } _resolveValidationManifestPath (manifestPath) { if (!path.isAbsolute(manifestPath)) { this._setError('Offline Test Optimization validation manifest path must be absolute.') return } const resolvedManifestPath = path.resolve(manifestPath) const testOptimizationPath = path.dirname(resolvedManifestPath) const fixtureRoot = path.dirname(testOptimizationPath) if (path.basename(resolvedManifestPath) !== MANIFEST_FILE_NAME || path.basename(testOptimizationPath) !== PLAN_FOLDER) { this._setError('Offline Test Optimization validation manifest must use the fixed .testoptimization layout.') return } try { assertPathComponentsAreNotSymlinks(fixtureRoot, resolvedManifestPath) assertRegularFixtureFile(resolvedManifestPath) return resolvedManifestPath } catch (err) { this._setError(err.message) } } _resolveRunfilePath (manifestFile) { if (fs.existsSync(manifestFile)) { return manifestFile } const runfilesDir = this._env[ENV_RUNFILES_DIR] if (runfilesDir) { const candidate = path.join(runfilesDir, manifestFile) if (fs.existsSync(candidate)) { return candidate } } const runfilesManifestCandidate = this._resolveRunfilePathFromManifest(manifestFile) if (runfilesManifestCandidate) { return runfilesManifestCandidate } const testSrcdir = this._env[ENV_TEST_SRCDIR] if (testSrcdir) { const candidate = path.join(testSrcdir, manifestFile) if (fs.existsSync(candidate)) { return candidate } } return manifestFile } _resolveRunfilePathFromManifest (manifestFile) { const runfilesManifest = this._env[ENV_RUNFILES_MANIFEST_FILE] if (!runfilesManifest || !fs.existsSync(runfilesManifest)) return try { const lines = fs.readFileSync(runfilesManifest, 'utf8').split('\n') for (const line of lines) { const separatorIndex = line.indexOf(RUNFILES_MANIFEST_SEPARATOR) if (separatorIndex <= 0) continue if (line.slice(0, separatorIndex) !== manifestFile) continue const resolvedPath = line.slice(separatorIndex + 1).trim() if (resolvedPath) return resolvedPath } } catch (err) { log.debug('Failed to resolve Test Optimization HTTP cache manifest from %s: %s', runfilesManifest, err.message) } } _readManifestVersion (manifestPath) { try { this._assertValidationFixtureFile(manifestPath) return parseManifestVersion(fs.readFileSync(manifestPath, 'utf8')) } catch (err) { if (this._validationManifestPath) this._setError(err.message) log.debug('Failed to read Test Optimization HTTP cache manifest %s: %s', manifestPath, err.message) } } _readFile (fileName) { if (!this.isAvailable()) return CACHE_MISS const filePath = path.join(this._httpCachePath, fileName) try { this._assertValidationFixtureFile(filePath) log.debug('Reading Test Optimization HTTP cache file %s', filePath) return fs.readFileSync(filePath, 'utf8') } catch (err) { if (this._validationManifestPath) this._setError(err.message) log.debug('Test Optimization HTTP cache file %s could not be read: %s', filePath, err.message) return CACHE_MISS } } _logInvalidCacheFile (fileName, err) { if (this._validationManifestPath) { this._setError(`Invalid offline Test Optimization ${fileName} fixture: ${err.message}`) } log.debug('Test Optimization HTTP cache file %s could not be parsed: %s', fileName, err.message) } _assertValidationFixtureFile (filePath) { if (!this._validationManifestPath) return const fixtureRoot = path.dirname(path.dirname(this._manifestPath)) assertPathComponentsAreNotSymlinks(fixtureRoot, filePath) const stat = assertRegularFixtureFile(filePath) if (stat.size > this._maxFileBytes) { throw new Error( `Offline Test Optimization fixture ${path.basename(filePath)} exceeds ${this._maxFileBytes} bytes.` ) } } _parsePayload (payload, fileName) { if (!this._validationManifestPath) return payload const parsed = JSON.parse(payload) assertBoundedValidationValue(parsed, fileName) return parsed } _setError (message) { this._lastError = new Error(message) } _disable () { this._available = false } } function assertPathComponentsAreNotSymlinks (root, filename) { const resolvedRoot = path.resolve(root) const resolvedFilename = path.resolve(filename) const rootStat = fs.lstatSync(resolvedRoot) if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) { throw new Error(`Offline Test Optimization fixture root must be a regular directory: ${resolvedRoot}`) } const relative = path.relative(resolvedRoot, resolvedFilename) if (relative.startsWith('..') || path.isAbsolute(relative)) { throw new Error('Offline Test Optimization fixture path escapes its validation root.') } let current = resolvedRoot for (const segment of relative ? relative.split(path.sep) : []) { current = path.join(current, segment) const stat = fs.lstatSync(current) if (stat.isSymbolicLink()) { throw new Error(`Offline Test Optimization fixture path contains a symbolic link: ${current}`) } } } function assertRegularFixtureFile (filename) { const stat = fs.lstatSync(filename) if (!stat.isFile()) { throw new Error(`Offline Test Optimization fixture is not a regular file: ${filename}`) } if (stat.nlink > 1) { throw new Error(`Offline Test Optimization fixture must not be hard-linked: ${filename}`) } return stat } function assertBoundedValidationValue (value, fileName) { const pending = [{ depth: 0, value }] let entries = 0 while (pending.length > 0) { const current = pending.pop() if (current.depth > DEFAULT_VALIDATION_MAX_NESTING_DEPTH) { throw new Error(`${fileName} exceeds the validation nesting limit.`) } if (typeof current.value === 'string' && Buffer.byteLength(current.value) > DEFAULT_VALIDATION_MAX_STRING_BYTES) { throw new Error(`${fileName} contains a string that exceeds the validation limit.`) } if (!current.value || typeof current.value !== 'object') continue const values = Array.isArray(current.value) ? current.value : Object.values(current.value) entries += values.length if (entries > DEFAULT_VALIDATION_MAX_ENTRIES) { throw new Error(`${fileName} exceeds the validation collection-entry limit.`) } for (const nestedValue of values) { pending.push({ depth: current.depth + 1, value: nestedValue }) } } } module.exports = { CACHE_MISS, DEFAULT_VALIDATION_MAX_FILE_BYTES, TestOptimizationHttpCache, }