diff --git a/packages/dd-trace/src/ci-visibility/early-flake-detection/get-known-tests.js b/packages/dd-trace/src/ci-visibility/early-flake-detection/get-known-tests.js index 8a8d934792b..5aa9cfb070f 100644 --- a/packages/dd-trace/src/ci-visibility/early-flake-detection/get-known-tests.js +++ b/packages/dd-trace/src/ci-visibility/early-flake-detection/get-known-tests.js @@ -16,12 +16,17 @@ const { } = require('../../ci-visibility/telemetry') const { getNumFromKnownTests } = require('../../plugins/util/test') +const { buildCacheKey, writeToCache, withCache } = require('../requests/fs-cache') const MAX_KNOWN_TESTS_PAGES = 10_000 /** * Deep-merges page tests into aggregate. * Structure: { module: { suite: [testName, ...] } } + * + * @param {object | null} aggregate + * @param {object | null} page + * @returns {object | null} */ function mergeKnownTests (aggregate, page) { if (!page) return aggregate @@ -62,6 +67,71 @@ function getKnownTests ({ runtimeName, runtimeVersion, custom, +}, done) { + const cacheKey = buildCacheKey('known-tests', [ + sha, service, env, repositoryUrl, osPlatform, osVersion, osArchitecture, + runtimeName, runtimeVersion, custom, + ]) + + withCache(cacheKey, (activeCacheKey, cb) => { + fetchFromApi({ + url, + isEvpProxy, + evpProxyPrefix, + isGzipCompatible, + env, + service, + repositoryUrl, + sha, + osVersion, + osPlatform, + osArchitecture, + runtimeName, + runtimeVersion, + custom, + cacheKey: activeCacheKey, + }, cb) + }, done) +} + +/** + * Fetches known tests from the API with cursor-based pagination and writes the + * result to cache on success. + * + * @param {object} params + * @param {string} params.url + * @param {boolean} params.isEvpProxy + * @param {string} params.evpProxyPrefix + * @param {boolean} params.isGzipCompatible + * @param {string} params.env + * @param {string} params.service + * @param {string} params.repositoryUrl + * @param {string} params.sha + * @param {string} params.osVersion + * @param {string} params.osPlatform + * @param {string} params.osArchitecture + * @param {string} params.runtimeName + * @param {string} params.runtimeVersion + * @param {object} [params.custom] + * @param {string | null} params.cacheKey + * @param {Function} done + */ +function fetchFromApi ({ + url, + isEvpProxy, + evpProxyPrefix, + isGzipCompatible, + env, + service, + repositoryUrl, + sha, + osVersion, + osPlatform, + osArchitecture, + runtimeName, + runtimeVersion, + custom, + cacheKey, }, done) { const options = { path: '/api/v2/ci/libraries/tests', @@ -166,7 +236,9 @@ function getKnownTests ({ distributionMetric(TELEMETRY_KNOWN_TESTS_RESPONSE_TESTS, {}, numTests) distributionMetric(TELEMETRY_KNOWN_TESTS_RESPONSE_BYTES, {}, totalResponseBytes) - log.debug('Number of received known tests:', numTests) + log.debug('Number of received known tests: %d', numTests) + + writeToCache(cacheKey, aggregateTests) done(null, aggregateTests) } catch (err) { diff --git a/packages/dd-trace/src/ci-visibility/intelligent-test-runner/get-skippable-suites.js b/packages/dd-trace/src/ci-visibility/intelligent-test-runner/get-skippable-suites.js index b40555bb149..29a154ae2c1 100644 --- a/packages/dd-trace/src/ci-visibility/intelligent-test-runner/get-skippable-suites.js +++ b/packages/dd-trace/src/ci-visibility/intelligent-test-runner/get-skippable-suites.js @@ -13,6 +13,7 @@ const { TELEMETRY_ITR_SKIPPABLE_TESTS_RESPONSE_TESTS, TELEMETRY_ITR_SKIPPABLE_TESTS_RESPONSE_BYTES, } = require('../../ci-visibility/telemetry') +const { buildCacheKey, writeToCache, withCache } = require('../requests/fs-cache') function getSkippableSuites ({ url, @@ -30,6 +31,76 @@ function getSkippableSuites ({ runtimeVersion, custom, testLevel = 'suite', +}, done) { + const cacheKey = buildCacheKey('skippable', [ + sha, service, env, repositoryUrl, osPlatform, osVersion, osArchitecture, + runtimeName, runtimeVersion, testLevel, custom, + ]) + + withCache(cacheKey, (activeCacheKey, cb) => { + fetchFromApi({ + url, + isEvpProxy, + evpProxyPrefix, + isGzipCompatible, + env, + service, + repositoryUrl, + sha, + osVersion, + osPlatform, + osArchitecture, + runtimeName, + runtimeVersion, + custom, + testLevel, + cacheKey: activeCacheKey, + }, cb) + }, (err, data) => { + if (err) return done(err) + done(null, data.skippableSuites, data.correlationId) + }) +} + +/** + * Fetches skippable suites from the API and writes the result to cache on success. + * + * @param {object} params + * @param {string} params.url + * @param {boolean} params.isEvpProxy + * @param {string} params.evpProxyPrefix + * @param {boolean} params.isGzipCompatible + * @param {string} params.env + * @param {string} params.service + * @param {string} params.repositoryUrl + * @param {string} params.sha + * @param {string} params.osVersion + * @param {string} params.osPlatform + * @param {string} params.osArchitecture + * @param {string} params.runtimeName + * @param {string} params.runtimeVersion + * @param {object} [params.custom] + * @param {string} [params.testLevel] + * @param {string | null} params.cacheKey + * @param {Function} done + */ +function fetchFromApi ({ + url, + isEvpProxy, + evpProxyPrefix, + isGzipCompatible, + env, + service, + repositoryUrl, + sha, + osVersion, + osPlatform, + osArchitecture, + runtimeName, + runtimeVersion, + custom, + testLevel, + cacheKey, }, done) { const options = { path: '/api/v2/ci/tests/skippable', @@ -109,7 +180,11 @@ function getSkippableSuites ({ ) distributionMetric(TELEMETRY_ITR_SKIPPABLE_TESTS_RESPONSE_BYTES, {}, res.length) log.debug('Number of received skippable %ss:', testLevel, skippableSuites.length) - done(null, skippableSuites, correlationId) + + const result = { skippableSuites, correlationId } + writeToCache(cacheKey, result) + + done(null, result) } catch (err) { done(err) } diff --git a/packages/dd-trace/src/ci-visibility/requests/fs-cache.js b/packages/dd-trace/src/ci-visibility/requests/fs-cache.js new file mode 100644 index 00000000000..443bb3ea6e6 --- /dev/null +++ b/packages/dd-trace/src/ci-visibility/requests/fs-cache.js @@ -0,0 +1,259 @@ +'use strict' + +const fs = require('node:fs') +const path = require('node:path') +const { createHash } = require('node:crypto') +const { tmpdir } = require('node:os') + +const log = require('../../log') +const { getValueFromEnvSources } = require('../../config/helper') + +const CACHE_TTL_MS = 30 * 60 * 1000 // 30 minutes +const CACHE_LOCK_POLL_MS = 500 +const CACHE_LOCK_TIMEOUT_MS = 120_000 // 2 minutes +const CACHE_LOCK_HEARTBEAT_MS = 30_000 // 30 seconds + +/** + * Returns whether the filesystem cache is enabled via the env var. + * + * @returns {boolean} + */ +function isCacheEnabled () { + const { isTrue } = require('../../util') + return isTrue(getValueFromEnvSources('DD_EXPERIMENTAL_TEST_REQUESTS_FS_CACHE')) +} + +/** + * Builds a deterministic cache key by hashing arbitrary key-value parts. + * + * @param {string} prefix - Cache file prefix (e.g. 'known-tests', 'skippable', 'test-mgmt') + * @param {Array} parts - Values that uniquely identify the cached response + * @returns {string} + */ +function buildCacheKey (prefix, parts) { + const hash = createHash('sha256').update(JSON.stringify(parts)).digest('hex').slice(0, 16) + return `${prefix}-${hash}` +} + +/** + * @param {string} cacheKey + * @returns {string} + */ +function getCachePath (cacheKey) { + return path.join(tmpdir(), `dd-${cacheKey}.json`) +} + +/** + * @param {string} cacheKey + * @returns {string} + */ +function getLockPath (cacheKey) { + return path.join(tmpdir(), `dd-${cacheKey}.lock`) +} + +/** + * Attempts to read cached data from the filesystem. + * + * @param {string} cacheKey + * @returns {{ data: unknown } | undefined} + */ +function readFromCache (cacheKey) { + const cachePath = getCachePath(cacheKey) + try { + const raw = fs.readFileSync(cachePath, 'utf8') + const parsed = JSON.parse(raw) + if (!Object.hasOwn(parsed, 'data')) { + log.debug('%s cache file has no data field, ignoring', cacheKey) + return + } + const { timestamp, data } = parsed + if (Date.now() - timestamp > CACHE_TTL_MS) { + log.debug('%s cache expired (age: %d ms)', cacheKey, Date.now() - timestamp) + return + } + log.debug('%s cache hit', cacheKey) + return { data } + } catch { + // Cache file missing, corrupt, or unreadable — treat as cache miss + } +} + +/** + * Writes data to the filesystem cache atomically. + * + * @param {string} cacheKey + * @param {unknown} data + */ +function writeToCache (cacheKey, data) { + if (!cacheKey) return + const cachePath = getCachePath(cacheKey) + const tmpPath = cachePath + '.tmp.' + process.pid + try { + fs.writeFileSync(tmpPath, JSON.stringify({ timestamp: Date.now(), data }), 'utf8') + fs.renameSync(tmpPath, cachePath) + log.debug('Cache written: %s', cachePath) + } catch (err) { + log.error('Failed to write cache %s: %s', cacheKey, err.message) + try { fs.unlinkSync(tmpPath) } catch { /* ignore */ } + } +} + +/** + * Attempts to acquire an exclusive lock using O_CREAT|O_EXCL. + * + * @param {string} cacheKey + * @returns {boolean} + */ +function tryAcquireLock (cacheKey) { + const lockPath = getLockPath(cacheKey) + try { + const fd = fs.openSync(lockPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY) + fs.writeSync(fd, String(Date.now())) + fs.closeSync(fd) + return true + } catch { + return false + } +} + +/** + * Removes the lock file. + * + * @param {string} cacheKey + */ +function releaseLock (cacheKey) { + try { fs.unlinkSync(getLockPath(cacheKey)) } catch { /* ignore */ } +} + +/** + * Updates the lock file timestamp so waiters know the owner is still alive. + * + * @param {string} cacheKey + */ +function touchLock (cacheKey) { + const lockPath = getLockPath(cacheKey) + const tmpPath = lockPath + '.tmp.' + process.pid + try { + fs.writeFileSync(tmpPath, String(Date.now())) + fs.renameSync(tmpPath, lockPath) + } catch { + try { fs.unlinkSync(tmpPath) } catch { /* ignore */ } + } +} + +/** + * Starts a periodic heartbeat that touches the lock file. + * Returns a function that stops the heartbeat and releases the lock. + * + * @param {string} cacheKey + * @returns {Function} + */ +function startLockHeartbeat (cacheKey) { + const interval = setInterval(() => touchLock(cacheKey), CACHE_LOCK_HEARTBEAT_MS) + interval.unref() + return () => { + clearInterval(interval) + try { fs.unlinkSync(getLockPath(cacheKey)) } catch { /* ignore */ } + } +} + +/** + * Checks whether the lock file is stale (older than the lock timeout). + * + * @param {string} cacheKey + * @returns {boolean} + */ +function isLockStale (cacheKey) { + try { + const content = fs.readFileSync(getLockPath(cacheKey), 'utf8') + return Date.now() - Number(content) > CACHE_LOCK_TIMEOUT_MS + } catch { + return true + } +} + +/** + * Polls until the cache file appears or the timeout is reached. + * + * @param {string} cacheKey + * @param {Function} fetchFn - function(done) that fetches from the API + * @param {Function} done - callback(err, ...results) + */ +function waitForCache (cacheKey, fetchFn, done) { + const poll = () => { + const cached = readFromCache(cacheKey) + if (cached) { + return done(null, cached.data) + } + if (isLockStale(cacheKey)) { + log.debug('%s lock is stale, attempting takeover', cacheKey) + releaseLock(cacheKey) + if (!tryAcquireLock(cacheKey)) { + return setTimeout(poll, CACHE_LOCK_POLL_MS) + } + + const cachedAfterTakeover = readFromCache(cacheKey) + if (cachedAfterTakeover) { + releaseLock(cacheKey) + return done(null, cachedAfterTakeover.data) + } + + const stopHeartbeat = startLockHeartbeat(cacheKey) + return fetchFn((err, ...results) => { + stopHeartbeat() + done(err, ...results) + }) + } + setTimeout(poll, CACHE_LOCK_POLL_MS) + } + poll() +} + +/** + * Wraps a fetch function with filesystem-based caching and cross-process deduplication. + * + * When cache is disabled (env var not set), calls fetchFn directly. + * When enabled, checks cache → acquires lock → fetches → writes cache → releases lock. + * + * @param {string} cacheKey - Unique cache key for this request + * @param {Function} fetchFn - function(cacheKey, done) that performs the API request. + * Must call writeToCache(cacheKey, data) on success before calling done(null, data). + * @param {Function} done - callback(err, ...results) + */ +function withCache (cacheKey, fetchFn, done) { + if (!isCacheEnabled()) { + return fetchFn(null, done) + } + + // Fast path: cache hit + const cached = readFromCache(cacheKey) + if (cached) { + return done(null, cached.data) + } + + // Try to become the fetcher (lock owner) + const isLockOwner = tryAcquireLock(cacheKey) + + if (!isLockOwner) { + log.debug('%s lock held by another process, waiting for cache', cacheKey) + return waitForCache(cacheKey, (cb) => fetchFn(cacheKey, cb), done) + } + + // This process owns the lock — start heartbeat and fetch + const stopHeartbeat = startLockHeartbeat(cacheKey) + + fetchFn(cacheKey, (err, ...results) => { + stopHeartbeat() + done(err, ...results) + }) +} + +module.exports = { + isCacheEnabled, + buildCacheKey, + readFromCache, + writeToCache, + withCache, + getCachePath, + getLockPath, +} diff --git a/packages/dd-trace/src/ci-visibility/test-management/get-test-management-tests.js b/packages/dd-trace/src/ci-visibility/test-management/get-test-management-tests.js index 4491cb2aab6..f1220bc0111 100644 --- a/packages/dd-trace/src/ci-visibility/test-management/get-test-management-tests.js +++ b/packages/dd-trace/src/ci-visibility/test-management/get-test-management-tests.js @@ -15,6 +15,8 @@ const { TELEMETRY_TEST_MANAGEMENT_TESTS_RESPONSE_BYTES, } = require('../telemetry') +const { buildCacheKey, writeToCache, withCache } = require('../requests/fs-cache') + // Calculate the number of tests from the test management tests response, which has a shape like: // { module: { suites: { suite: { tests: { testName: { properties: {...} } } } } } } function getNumFromTestManagementTests (testManagementTests) { @@ -48,6 +50,58 @@ function getTestManagementTests ({ commitHeadSha, commitHeadMessage, branch, +}, done) { + const effectiveSha = commitHeadSha || sha + const cacheKey = buildCacheKey('test-mgmt', [ + effectiveSha, repositoryUrl, branch, + ]) + + withCache(cacheKey, (activeCacheKey, cb) => { + fetchFromApi({ + url, + isEvpProxy, + evpProxyPrefix, + isGzipCompatible, + repositoryUrl, + commitMessage, + sha, + commitHeadSha, + commitHeadMessage, + branch, + cacheKey: activeCacheKey, + }, cb) + }, done) +} + +/** + * Fetches test management tests from the API and writes the result to cache on success. + * + * @param {object} params + * @param {string} params.url + * @param {boolean} params.isEvpProxy + * @param {string} params.evpProxyPrefix + * @param {boolean} params.isGzipCompatible + * @param {string} params.repositoryUrl + * @param {string} [params.commitMessage] + * @param {string} params.sha + * @param {string} [params.commitHeadSha] + * @param {string} [params.commitHeadMessage] + * @param {string} [params.branch] + * @param {string | null} params.cacheKey + * @param {Function} done + */ +function fetchFromApi ({ + url, + isEvpProxy, + evpProxyPrefix, + isGzipCompatible, + repositoryUrl, + commitMessage, + sha, + commitHeadSha, + commitHeadMessage, + branch, + cacheKey, }, done) { const options = { path: '/api/v2/test/libraries/test-management/tests', @@ -110,6 +164,8 @@ function getTestManagementTests ({ log.debug('Test management tests received: %j', testManagementTests) + writeToCache(cacheKey, testManagementTests) + done(null, testManagementTests) } catch (err) { done(err) diff --git a/packages/dd-trace/src/config/supported-configurations.json b/packages/dd-trace/src/config/supported-configurations.json index 5bca7736dc9..b0baabe7870 100644 --- a/packages/dd-trace/src/config/supported-configurations.json +++ b/packages/dd-trace/src/config/supported-configurations.json @@ -773,6 +773,13 @@ "default": "false" } ], + "DD_EXPERIMENTAL_TEST_REQUESTS_FS_CACHE": [ + { + "implementation": "A", + "type": "boolean", + "default": "false" + } + ], "DD_EXTERNAL_ENV": [ { "implementation": "A", diff --git a/packages/dd-trace/test/ci-visibility/early-flake-detection/get-known-tests.spec.js b/packages/dd-trace/test/ci-visibility/early-flake-detection/get-known-tests.spec.js new file mode 100644 index 00000000000..e3005cb9379 --- /dev/null +++ b/packages/dd-trace/test/ci-visibility/early-flake-detection/get-known-tests.spec.js @@ -0,0 +1,331 @@ +'use strict' + +const assert = require('node:assert/strict') +const fs = require('node:fs') + +const { describe, it, beforeEach, afterEach } = require('mocha') +const nock = require('nock') + +require('../../setup/core') + +const { getKnownTests } = require('../../../src/ci-visibility/early-flake-detection/get-known-tests') +const { + buildCacheKey, + getCachePath, + getLockPath, +} = require('../../../src/ci-visibility/requests/fs-cache') + +const BASE_URL = 'http://localhost:8126' + +const DEFAULT_PARAMS = { + url: BASE_URL, + isEvpProxy: false, + evpProxyPrefix: '', + isGzipCompatible: false, + env: 'ci', + service: 'my-service', + repositoryUrl: 'https://github.com/example/repo', + sha: 'abc123', + osVersion: '22.04', + osPlatform: 'linux', + osArchitecture: 'x64', + runtimeName: 'node', + runtimeVersion: '18.0.0', + custom: {}, +} + +const KNOWN_TESTS_RESPONSE = { + data: { + attributes: { + tests: { + jest: { + 'suite1.spec.js': ['test1', 'test2'], + 'suite2.spec.js': ['test3'], + }, + }, + }, + }, +} + +const EMPTY_KNOWN_TESTS_RESPONSE = { + data: { + attributes: { + tests: null, + }, + }, +} + +function cacheKeyForParams (params) { + return buildCacheKey('known-tests', [ + params.sha, params.service, params.env, params.repositoryUrl, + params.osPlatform, params.osVersion, params.osArchitecture, + params.runtimeName, params.runtimeVersion, params.custom, + ]) +} + +function cleanup (params) { + const key = cacheKeyForParams(params) + try { fs.unlinkSync(getCachePath(key)) } catch { /* ignore */ } + try { fs.unlinkSync(getLockPath(key)) } catch { /* ignore */ } +} + +describe('get-known-tests', () => { + beforeEach(() => { + process.env.DD_API_KEY = 'test-api-key' + process.env.DD_EXPERIMENTAL_TEST_REQUESTS_FS_CACHE = 'true' + cleanup(DEFAULT_PARAMS) + }) + + afterEach(() => { + delete process.env.DD_API_KEY + delete process.env.DD_EXPERIMENTAL_TEST_REQUESTS_FS_CACHE + cleanup(DEFAULT_PARAMS) + nock.cleanAll() + }) + + it('should fetch from API and return known tests', (done) => { + nock(BASE_URL) + .post('/api/v2/ci/libraries/tests') + .reply(200, JSON.stringify(KNOWN_TESTS_RESPONSE)) + + getKnownTests(DEFAULT_PARAMS, (err, knownTests) => { + assert.strictEqual(err, null) + assert.deepStrictEqual(knownTests, KNOWN_TESTS_RESPONSE.data.attributes.tests) + done() + }) + }) + + it('should write to cache after a successful fetch', (done) => { + nock(BASE_URL) + .post('/api/v2/ci/libraries/tests') + .reply(200, JSON.stringify(KNOWN_TESTS_RESPONSE)) + + getKnownTests(DEFAULT_PARAMS, (err) => { + assert.strictEqual(err, null) + + const key = cacheKeyForParams(DEFAULT_PARAMS) + const cachePath = getCachePath(key) + assert.ok(fs.existsSync(cachePath), 'cache file should exist') + + const cached = JSON.parse(fs.readFileSync(cachePath, 'utf8')) + assert.deepStrictEqual(cached.data, KNOWN_TESTS_RESPONSE.data.attributes.tests) + assert.ok(typeof cached.timestamp === 'number') + done() + }) + }) + + it('should return cached data on second call without hitting API', (done) => { + const scope = nock(BASE_URL) + .post('/api/v2/ci/libraries/tests') + .reply(200, JSON.stringify(KNOWN_TESTS_RESPONSE)) + + getKnownTests(DEFAULT_PARAMS, (err, firstResult) => { + assert.strictEqual(err, null) + assert.ok(scope.isDone(), 'API should have been called') + + // Second call should NOT hit the API + const secondScope = nock(BASE_URL) + .post('/api/v2/ci/libraries/tests') + .reply(200, JSON.stringify(KNOWN_TESTS_RESPONSE)) + + getKnownTests(DEFAULT_PARAMS, (err, secondResult) => { + assert.strictEqual(err, null) + assert.deepStrictEqual(secondResult, firstResult) + assert.strictEqual(secondScope.isDone(), false, 'API should NOT have been called on cache hit') + done() + }) + }) + }) + + it('should return cached empty known tests on second call without hitting API', (done) => { + const scope = nock(BASE_URL) + .post('/api/v2/ci/libraries/tests') + .reply(200, JSON.stringify(EMPTY_KNOWN_TESTS_RESPONSE)) + + getKnownTests(DEFAULT_PARAMS, (err, firstResult) => { + assert.strictEqual(err, null) + assert.strictEqual(firstResult, null) + assert.ok(scope.isDone(), 'API should have been called') + + const secondScope = nock(BASE_URL) + .post('/api/v2/ci/libraries/tests') + .reply(200, JSON.stringify(EMPTY_KNOWN_TESTS_RESPONSE)) + + getKnownTests(DEFAULT_PARAMS, (err, secondResult) => { + assert.strictEqual(err, null) + assert.strictEqual(secondResult, null) + assert.strictEqual(secondScope.isDone(), false, 'API should NOT have been called on cache hit') + done() + }) + }) + }) + + it('should not use cache if TTL has expired', (done) => { + // Write an expired cache entry + const key = cacheKeyForParams(DEFAULT_PARAMS) + const cachePath = getCachePath(key) + const expiredData = { + timestamp: Date.now() - (31 * 60 * 1000), // 31 minutes ago + data: { old: { suite: ['old-test'] } }, + } + fs.writeFileSync(cachePath, JSON.stringify(expiredData), 'utf8') + + nock(BASE_URL) + .post('/api/v2/ci/libraries/tests') + .reply(200, JSON.stringify(KNOWN_TESTS_RESPONSE)) + + getKnownTests(DEFAULT_PARAMS, (err, knownTests) => { + assert.strictEqual(err, null) + assert.deepStrictEqual(knownTests, KNOWN_TESTS_RESPONSE.data.attributes.tests) + done() + }) + }) + + it('should use different cache keys for different SHAs', (done) => { + const scope1 = nock(BASE_URL) + .post('/api/v2/ci/libraries/tests') + .reply(200, JSON.stringify(KNOWN_TESTS_RESPONSE)) + + getKnownTests(DEFAULT_PARAMS, (err) => { + assert.strictEqual(err, null) + assert.ok(scope1.isDone()) + + const otherParams = { ...DEFAULT_PARAMS, sha: 'different-sha' } + + const scope2 = nock(BASE_URL) + .post('/api/v2/ci/libraries/tests') + .reply(200, JSON.stringify(KNOWN_TESTS_RESPONSE)) + + getKnownTests(otherParams, (err) => { + assert.strictEqual(err, null) + assert.ok(scope2.isDone(), 'API should be called for a different SHA') + cleanup(otherParams) + done() + }) + }) + }) + + it('should handle API errors without caching', function (done) { + this.timeout(15_000) + + // The request module retries 5xx once, so we need two replies + nock(BASE_URL) + .post('/api/v2/ci/libraries/tests') + .reply(500, 'Internal Server Error') + .post('/api/v2/ci/libraries/tests') + .reply(500, 'Internal Server Error') + + getKnownTests(DEFAULT_PARAMS, (err) => { + assert.ok(err) + + const key = cacheKeyForParams(DEFAULT_PARAMS) + assert.strictEqual(fs.existsSync(getCachePath(key)), false, 'cache should not be written on error') + done() + }) + }) + + describe('lock contention', () => { + it('should wait for cache when lock is held and cache appears', (done) => { + const key = cacheKeyForParams(DEFAULT_PARAMS) + const lockPath = getLockPath(key) + + // Simulate another process holding the lock + fs.writeFileSync(lockPath, String(Date.now())) + + // Start a getKnownTests call that will wait for the lock + getKnownTests(DEFAULT_PARAMS, (err, knownTests) => { + assert.strictEqual(err, null) + assert.deepStrictEqual(knownTests, KNOWN_TESTS_RESPONSE.data.attributes.tests) + done() + }) + + // Simulate the lock holder writing the cache after a short delay + setTimeout(() => { + const cachePath = getCachePath(key) + fs.writeFileSync(cachePath, JSON.stringify({ + timestamp: Date.now(), + data: KNOWN_TESTS_RESPONSE.data.attributes.tests, + }), 'utf8') + }, 600) + }) + + it('should fall back to direct fetch when lock is stale', function (done) { + this.timeout(10_000) + + const key = cacheKeyForParams(DEFAULT_PARAMS) + const lockPath = getLockPath(key) + + // Simulate a stale lock (timestamp far in the past) + fs.writeFileSync(lockPath, String(Date.now() - 200_000)) + + nock(BASE_URL) + .post('/api/v2/ci/libraries/tests') + .reply(200, JSON.stringify(KNOWN_TESTS_RESPONSE)) + + getKnownTests(DEFAULT_PARAMS, (err, knownTests) => { + assert.strictEqual(err, null) + assert.deepStrictEqual(knownTests, KNOWN_TESTS_RESPONSE.data.attributes.tests) + done() + }) + }) + + it('should only fetch once when multiple callers observe a stale lock', function (done) { + this.timeout(10_000) + + const key = cacheKeyForParams(DEFAULT_PARAMS) + const lockPath = getLockPath(key) + let numDoneCalls = 0 + + fs.writeFileSync(lockPath, String(Date.now() - 200_000)) + + const scope = nock(BASE_URL) + .post('/api/v2/ci/libraries/tests') + .reply(200, JSON.stringify(KNOWN_TESTS_RESPONSE)) + + const onDone = (err, knownTests) => { + assert.strictEqual(err, null) + assert.deepStrictEqual(knownTests, KNOWN_TESTS_RESPONSE.data.attributes.tests) + if (++numDoneCalls === 2) { + assert.ok(scope.isDone(), 'API should have been called exactly once') + done() + } + } + + getKnownTests(DEFAULT_PARAMS, onDone) + getKnownTests(DEFAULT_PARAMS, onDone) + }) + }) + + it('should clean up lock after successful fetch', (done) => { + nock(BASE_URL) + .post('/api/v2/ci/libraries/tests') + .reply(200, JSON.stringify(KNOWN_TESTS_RESPONSE)) + + getKnownTests(DEFAULT_PARAMS, (err) => { + assert.strictEqual(err, null) + + const key = cacheKeyForParams(DEFAULT_PARAMS) + assert.strictEqual(fs.existsSync(getLockPath(key)), false, 'lock should be cleaned up') + done() + }) + }) + + it('should clean up lock after failed fetch', function (done) { + this.timeout(15_000) + + // The request module retries 5xx once, so we need two replies + nock(BASE_URL) + .post('/api/v2/ci/libraries/tests') + .reply(500, 'error') + .post('/api/v2/ci/libraries/tests') + .reply(500, 'error') + + getKnownTests(DEFAULT_PARAMS, (err) => { + assert.ok(err) + + const key = cacheKeyForParams(DEFAULT_PARAMS) + assert.strictEqual(fs.existsSync(getLockPath(key)), false, 'lock should be cleaned up on error') + done() + }) + }) +}) diff --git a/packages/dd-trace/test/ci-visibility/intelligent-test-runner/get-skippable-suites.spec.js b/packages/dd-trace/test/ci-visibility/intelligent-test-runner/get-skippable-suites.spec.js new file mode 100644 index 00000000000..75fbd554c8e --- /dev/null +++ b/packages/dd-trace/test/ci-visibility/intelligent-test-runner/get-skippable-suites.spec.js @@ -0,0 +1,124 @@ +'use strict' + +const assert = require('node:assert/strict') +const fs = require('node:fs') + +const { describe, it, beforeEach, afterEach } = require('mocha') +const nock = require('nock') + +require('../../setup/core') + +const { getSkippableSuites } = require('../../../src/ci-visibility/intelligent-test-runner/get-skippable-suites') +const { + buildCacheKey, + getCachePath, + getLockPath, +} = require('../../../src/ci-visibility/requests/fs-cache') + +const BASE_URL = 'http://localhost:8126' + +const DEFAULT_PARAMS = { + url: BASE_URL, + isEvpProxy: false, + evpProxyPrefix: '', + isGzipCompatible: false, + env: 'ci', + service: 'my-service', + repositoryUrl: 'https://github.com/example/repo', + sha: 'abc123', + osVersion: '22.04', + osPlatform: 'linux', + osArchitecture: 'x64', + runtimeName: 'node', + runtimeVersion: '18.0.0', + custom: {}, + testLevel: 'suite', +} + +const SKIPPABLE_RESPONSE = { + data: [ + { type: 'suite', attributes: { suite: 'suite1.spec.js' } }, + { type: 'suite', attributes: { suite: 'suite2.spec.js' } }, + ], + meta: { correlation_id: 'corr-123' }, +} + +function cacheKeyForParams (params) { + return buildCacheKey('skippable', [ + params.sha, params.service, params.env, params.repositoryUrl, + params.osPlatform, params.osVersion, params.osArchitecture, + params.runtimeName, params.runtimeVersion, params.testLevel, params.custom, + ]) +} + +function cleanup (params) { + const key = cacheKeyForParams(params) + try { fs.unlinkSync(getCachePath(key)) } catch { /* ignore */ } + try { fs.unlinkSync(getLockPath(key)) } catch { /* ignore */ } +} + +describe('get-skippable-suites', () => { + beforeEach(() => { + process.env.DD_API_KEY = 'test-api-key' + process.env.DD_EXPERIMENTAL_TEST_REQUESTS_FS_CACHE = 'true' + cleanup(DEFAULT_PARAMS) + }) + + afterEach(() => { + delete process.env.DD_API_KEY + delete process.env.DD_EXPERIMENTAL_TEST_REQUESTS_FS_CACHE + cleanup(DEFAULT_PARAMS) + nock.cleanAll() + }) + + it('should fetch from API and return skippable suites with correlationId', (done) => { + nock(BASE_URL) + .post('/api/v2/ci/tests/skippable') + .reply(200, JSON.stringify(SKIPPABLE_RESPONSE)) + + getSkippableSuites(DEFAULT_PARAMS, (err, skippableSuites, correlationId) => { + assert.strictEqual(err, null) + assert.deepStrictEqual(skippableSuites, ['suite1.spec.js', 'suite2.spec.js']) + assert.strictEqual(correlationId, 'corr-123') + done() + }) + }) + + it('should return cached data on second call preserving correlationId', (done) => { + const scope = nock(BASE_URL) + .post('/api/v2/ci/tests/skippable') + .reply(200, JSON.stringify(SKIPPABLE_RESPONSE)) + + getSkippableSuites(DEFAULT_PARAMS, (err, firstSuites, firstCorrelationId) => { + assert.strictEqual(err, null) + assert.ok(scope.isDone()) + + const secondScope = nock(BASE_URL) + .post('/api/v2/ci/tests/skippable') + .reply(200, JSON.stringify(SKIPPABLE_RESPONSE)) + + getSkippableSuites(DEFAULT_PARAMS, (err, secondSuites, secondCorrelationId) => { + assert.strictEqual(err, null) + assert.deepStrictEqual(secondSuites, firstSuites) + assert.strictEqual(secondCorrelationId, firstCorrelationId) + assert.strictEqual(secondScope.isDone(), false, 'API should NOT have been called on cache hit') + done() + }) + }) + }) + + it('should write cache and clean up lock after successful fetch', (done) => { + nock(BASE_URL) + .post('/api/v2/ci/tests/skippable') + .reply(200, JSON.stringify(SKIPPABLE_RESPONSE)) + + getSkippableSuites(DEFAULT_PARAMS, (err) => { + assert.strictEqual(err, null) + + const key = cacheKeyForParams(DEFAULT_PARAMS) + assert.ok(fs.existsSync(getCachePath(key)), 'cache file should exist') + assert.strictEqual(fs.existsSync(getLockPath(key)), false, 'lock should be cleaned up') + done() + }) + }) +}) diff --git a/packages/dd-trace/test/ci-visibility/test-management/get-test-management-tests.spec.js b/packages/dd-trace/test/ci-visibility/test-management/get-test-management-tests.spec.js new file mode 100644 index 00000000000..42ed2858ba5 --- /dev/null +++ b/packages/dd-trace/test/ci-visibility/test-management/get-test-management-tests.spec.js @@ -0,0 +1,126 @@ +'use strict' + +const assert = require('node:assert/strict') +const fs = require('node:fs') + +const { describe, it, beforeEach, afterEach } = require('mocha') +const nock = require('nock') + +require('../../setup/core') + +const { + getTestManagementTests, +} = require('../../../src/ci-visibility/test-management/get-test-management-tests') +const { + buildCacheKey, + getCachePath, + getLockPath, +} = require('../../../src/ci-visibility/requests/fs-cache') + +const BASE_URL = 'http://localhost:8126' + +const DEFAULT_PARAMS = { + url: BASE_URL, + isEvpProxy: false, + evpProxyPrefix: '', + isGzipCompatible: false, + repositoryUrl: 'https://github.com/example/repo', + commitMessage: 'fix tests', + sha: 'abc123', + commitHeadSha: '', + commitHeadMessage: '', + branch: 'main', +} + +const TEST_MGMT_RESPONSE = { + data: { + attributes: { + modules: { + jest: { + suites: { + 'suite1.spec.js': { + tests: { + 'test one': { properties: { disabled: true } }, + }, + }, + }, + }, + }, + }, + }, +} + +function cacheKeyForParams (params) { + const effectiveSha = params.commitHeadSha || params.sha + return buildCacheKey('test-mgmt', [effectiveSha, params.repositoryUrl, params.branch]) +} + +function cleanup (params) { + const key = cacheKeyForParams(params) + try { fs.unlinkSync(getCachePath(key)) } catch { /* ignore */ } + try { fs.unlinkSync(getLockPath(key)) } catch { /* ignore */ } +} + +describe('get-test-management-tests', () => { + beforeEach(() => { + process.env.DD_API_KEY = 'test-api-key' + process.env.DD_EXPERIMENTAL_TEST_REQUESTS_FS_CACHE = 'true' + cleanup(DEFAULT_PARAMS) + }) + + afterEach(() => { + delete process.env.DD_API_KEY + delete process.env.DD_EXPERIMENTAL_TEST_REQUESTS_FS_CACHE + cleanup(DEFAULT_PARAMS) + nock.cleanAll() + }) + + it('should fetch from API and return test management tests', (done) => { + nock(BASE_URL) + .post('/api/v2/test/libraries/test-management/tests') + .reply(200, JSON.stringify(TEST_MGMT_RESPONSE)) + + getTestManagementTests(DEFAULT_PARAMS, (err, tests) => { + assert.strictEqual(err, null) + assert.deepStrictEqual(tests, TEST_MGMT_RESPONSE.data.attributes.modules) + done() + }) + }) + + it('should return cached data on second call without hitting API', (done) => { + const scope = nock(BASE_URL) + .post('/api/v2/test/libraries/test-management/tests') + .reply(200, JSON.stringify(TEST_MGMT_RESPONSE)) + + getTestManagementTests(DEFAULT_PARAMS, (err, firstResult) => { + assert.strictEqual(err, null) + assert.ok(scope.isDone()) + + const secondScope = nock(BASE_URL) + .post('/api/v2/test/libraries/test-management/tests') + .reply(200, JSON.stringify(TEST_MGMT_RESPONSE)) + + getTestManagementTests(DEFAULT_PARAMS, (err, secondResult) => { + assert.strictEqual(err, null) + assert.deepStrictEqual(secondResult, firstResult) + assert.strictEqual(secondScope.isDone(), false, 'API should NOT have been called on cache hit') + done() + }) + }) + }) + + it('should write cache and clean up lock after successful fetch', (done) => { + nock(BASE_URL) + .post('/api/v2/test/libraries/test-management/tests') + .reply(200, JSON.stringify(TEST_MGMT_RESPONSE)) + + getTestManagementTests(DEFAULT_PARAMS, (err) => { + assert.strictEqual(err, null) + + const key = cacheKeyForParams(DEFAULT_PARAMS) + assert.ok(fs.existsSync(getCachePath(key)), 'cache file should exist') + assert.strictEqual(fs.existsSync(getLockPath(key)), false, 'lock should be cleaned up') + done() + }) + }) +})