UNPKG

dd-trace

Version:

Datadog APM tracing client for JavaScript

328 lines (307 loc) 11.4 kB
'use strict' const crypto = require('node:crypto') const fs = require('node:fs') const os = require('node:os') const path = require('node:path') const { getArtifactId } = require('./artifact-id') const { createFileSafely, ensureSafeDirectory } = require('./safe-files') const MAX_FIXTURE_FILE_BYTES = 1024 * 1024 const OFFLINE_FIXTURE_NONCE_PATTERN = /^[a-f0-9]{32}$/ const DEFAULT_SETTINGS = { code_coverage: false, tests_skipping: false, itr_enabled: false, require_git: false, early_flake_detection: { enabled: false, slow_test_retries: { '5s': 3, }, faulty_session_threshold: 100, }, flaky_test_retries_enabled: false, di_enabled: false, known_tests_enabled: false, test_management: { enabled: false, }, impacted_tests_enabled: false, coverage_report_upload_enabled: false, } /** * Creates one validator-controlled cache fixture outside the repository. * * @param {object} input fixture inputs * @param {string} input.approvedPlanSha256 approved execution-plan digest * @param {string} input.offlineFixtureNonce random fixture-root nonce from the approved plan * @param {object} input.framework framework manifest entry * @param {string} input.repositoryRoot repository checkout root * @param {string} input.scenarioName unique scenario execution name * @param {object} [input.settings] scenario settings overrides * @param {object} [input.knownTests] known-tests fixture contents * @param {object[]} [input.skippableTests] skippable-tests fixture contents * @param {object} [input.testManagementTests] managed-tests fixture contents * @returns {{manifestPath: string, root: string, files: object[]}} fixture details */ function createOfflineFixture ({ approvedPlanSha256, offlineFixtureNonce, framework, repositoryRoot, scenarioName, settings, knownTests = {}, skippableTests = [], testManagementTests = {}, }) { if (!/^[a-f0-9]{64}$/.test(approvedPlanSha256 || '')) { throw new Error('Offline validation requires an approved plan digest before creating fixtures.') } if (!OFFLINE_FIXTURE_NONCE_PATTERN.test(offlineFixtureNonce || '')) { throw new Error('Offline validation requires the fixture nonce from the approved execution plan.') } const { base, root } = getOfflineFixturePaths({ offlineFixtureNonce, framework, scenarioName }) if (isPathInside(path.resolve(repositoryRoot), base)) { throw new Error('Offline validation fixtures must be outside the repository checkout.') } ensurePrivateDirectory(base) if (fs.existsSync(root)) { throw new Error(`Offline validation fixture already exists and will not be replaced: ${root}`) } try { fs.mkdirSync(root, { recursive: true, mode: 0o700 }) ensureSafeDirectory(base, root, 'offline validation fixture directory') const testOptimizationRoot = path.join(root, '.testoptimization') const cacheRoot = path.join(testOptimizationRoot, 'cache', 'http') fs.mkdirSync(cacheRoot, { recursive: true, mode: 0o700 }) ensureSafeDirectory(root, cacheRoot, 'offline validation cache directory') const manifestPath = path.join(testOptimizationRoot, 'manifest.txt') const fixtureFiles = [ [manifestPath, '1\n'], [path.join(cacheRoot, 'settings.json'), JSON.stringify({ data: { attributes: mergeSettings(settings) }, })], [path.join(cacheRoot, 'known_tests.json'), JSON.stringify({ data: { attributes: { tests: knownTests } }, })], [path.join(cacheRoot, 'skippable_tests.json'), JSON.stringify({ data: skippableTests, meta: { correlation_id: 'dd-test-optimization-validation' }, })], [path.join(cacheRoot, 'test_management.json'), JSON.stringify({ data: { attributes: { modules: testManagementTests } }, })], ] for (const [filename, content] of fixtureFiles) { if (Buffer.byteLength(content) > MAX_FIXTURE_FILE_BYTES) { throw new Error(`Offline validation fixture exceeds ${MAX_FIXTURE_FILE_BYTES} bytes: ${filename}`) } createFileSafely(root, filename, content, 'offline validation fixture') } return { manifestPath, root, files: fixtureFiles.map(([filename, content]) => ({ filename, bytes: Buffer.byteLength(content) })), } } catch (error) { fs.rmSync(root, { recursive: true, force: true }) removeEmptyParents(path.dirname(root), base) throw error } } /** * Returns random, validator-controlled fixture paths bound to an approved execution plan. * * @param {object} input fixture path inputs * @param {string} input.offlineFixtureNonce random fixture-root nonce from the approved plan * @param {object} input.framework framework manifest entry * @param {string} input.scenarioName scenario execution name * @returns {{base: string, root: string}} fixture base and scenario root */ function getOfflineFixturePaths ({ offlineFixtureNonce, framework, scenarioName }) { if (!OFFLINE_FIXTURE_NONCE_PATTERN.test(offlineFixtureNonce || '')) { throw new Error('Invalid offline validation fixture nonce.') } const base = path.join(fs.realpathSync(os.tmpdir()), `dd-test-optimization-validation-${offlineFixtureNonce}`) return { base, root: path.join(base, getArtifactId(framework.id), getArtifactId(scenarioName)), } } /** * Returns the cache executions selected by a validator scenario selection. * * @param {string|null|undefined} requestedScenario selected validation scenario * @returns {string[]} cache execution names */ function getOfflineScenarioNames (requestedScenario) { const scenarios = new Set(['basic-reporting', 'basic-reporting-debug']) if (!requestedScenario || requestedScenario === 'ci-wiring') scenarios.add('ci-wiring') const advanced = requestedScenario ? requestedScenario === 'basic-reporting' || requestedScenario === 'ci-wiring' ? [] : [requestedScenario] : ['efd', 'atr', 'test-management'] for (const scenario of advanced) { scenarios.add(`${scenario}-baseline`) scenarios.add(scenario) scenarios.add(`${scenario}-debug`) } return [...scenarios] } /** * Hashes the validator-controlled inputs used to build one scenario fixture. * * @param {object} framework framework manifest entry * @param {string} scenarioName cache execution name * @returns {string} SHA-256 fixture recipe digest */ function getFixtureRecipeDigest (framework, scenarioName) { const generatedScenarioId = { atr: 'atr-fail-once', efd: 'basic-pass', 'test-management': 'test-management-target', }[scenarioName.replace(/-(?:baseline|debug)$/, '')] const recipe = { version: 1, framework: framework.framework, scenarioName, defaultSettings: DEFAULT_SETTINGS, settingsOverrides: getFixtureSettingsOverrides(scenarioName), testIdentities: framework.generatedTestStrategy?.scenarios?.find(scenario => { return scenario.id === generatedScenarioId })?.testIdentities || [], dynamicTestManagementIdentity: scenarioName.startsWith('test-management'), } return crypto.createHash('sha256').update(JSON.stringify(recipe)).digest('hex') } /** * Returns stable fixture recipe hashes included directly in the approval scope. * * @param {object} input recipe selection * @param {object[]} input.frameworks normalized manifest framework entries * @param {string[]} [input.selectedFrameworkIds] selected framework ids * @param {string|null} [input.requestedScenario] selected validation scenario * @returns {object[]} fixture recipe identities and hashes */ function getFixtureRecipeDigests ({ frameworks, selectedFrameworkIds = [], requestedScenario = null }) { const selected = new Set(selectedFrameworkIds) const entries = [] for (const framework of frameworks) { if (framework.status !== 'runnable' || (selected.size > 0 && !selected.has(framework.id))) continue for (const scenarioName of getOfflineScenarioNames(requestedScenario)) { entries.push({ frameworkId: framework.id, scenarioName, sha256: getFixtureRecipeDigest(framework, scenarioName), }) } } return entries } function getFixtureSettingsOverrides (scenarioName) { if (scenarioName === 'atr' || scenarioName === 'atr-debug') { return { flaky_test_retries_enabled: true } } if (scenarioName === 'efd' || scenarioName === 'efd-debug') { return { early_flake_detection: { enabled: true, slow_test_retries: { '5s': 3 }, faulty_session_threshold: 100, }, known_tests_enabled: true, } } if (scenarioName === 'test-management' || scenarioName === 'test-management-debug') { return { test_management: { enabled: true, attempt_to_fix_retries: 2 } } } return {} } /** * Removes a scenario fixture after its command has exited and output has been read. * * @param {string} fixtureRoot scenario fixture root */ function cleanupOfflineFixture (fixtureRoot) { const parent = path.dirname(path.dirname(fixtureRoot)) ensureSafeDirectory(parent, fixtureRoot, 'offline validation fixture cleanup') fs.rmSync(fixtureRoot, { recursive: true }) removeEmptyParents(path.dirname(fixtureRoot), parent) } function mergeSettings (settings = {}) { return { ...DEFAULT_SETTINGS, ...settings, early_flake_detection: { ...DEFAULT_SETTINGS.early_flake_detection, ...settings.early_flake_detection, }, test_management: { ...DEFAULT_SETTINGS.test_management, ...settings.test_management, }, } } /** * Creates or verifies a private validator-owned fixture directory. * * @param {string} directory fixture base directory * @returns {void} */ function ensurePrivateDirectory (directory) { try { fs.mkdirSync(directory, { mode: 0o700 }) } catch (error) { if (error.code !== 'EEXIST') throw error } const stat = fs.lstatSync(directory) const supportsPosixPermissions = typeof process.getuid === 'function' const ownerMismatch = supportsPosixPermissions && stat.uid !== process.getuid() const permissionsMismatch = supportsPosixPermissions && (stat.mode & 0o077) !== 0 if (!stat.isDirectory() || stat.isSymbolicLink() || ownerMismatch || permissionsMismatch) { throw new Error(`Offline validation fixture base is not a regular directory: ${directory}`) } } /** * Removes empty directories up to and including the fixture base. * * @param {string} directory first candidate directory * @param {string} stop fixture base directory * @returns {void} */ function removeEmptyParents (directory, stop) { let current = directory while (current !== stop && current.startsWith(`${stop}${path.sep}`)) { try { fs.rmdirSync(current) } catch { return } current = path.dirname(current) } try { fs.rmdirSync(stop) } catch {} } /** * Checks lexical path containment. * * @param {string} root candidate parent * @param {string} filename candidate child * @returns {boolean} whether the child is inside the parent */ function isPathInside (root, filename) { const relative = path.relative(root, filename) return relative === '' || (relative && !relative.startsWith('..') && !path.isAbsolute(relative)) } module.exports = { cleanupOfflineFixture, createOfflineFixture, DEFAULT_SETTINGS, getFixtureRecipeDigest, getFixtureRecipeDigests, getOfflineFixturePaths, getOfflineScenarioNames, MAX_FIXTURE_FILE_BYTES, }