Skip to content

Commit bcae91f

Browse files
calvinbayerclaude
andauthored
feat(ci-vis): add cursor-based pagination for known tests endpoint (#7866)
* feat(ci-vis): add cursor-based pagination for known tests endpoint Implement pagination support for the known tests API endpoint (api/v2/ci/libraries/tests) as part of SDTEST-2988. The client now sends page_info in requests and follows cursor-based pagination when the response includes page_info.has_next=true, merging tests across all pages into a single result. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(ci-vis): resolve lint errors in known tests pagination - Use ternary + spread operator instead of if/else + concat - Use printf-style log formatting instead of template literals Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Address review feedback: remove unnecessary export and middleware - Stop exporting mergeKnownTests (only used internally) - Remove express.json() middleware from known tests route (req.body unused) - Remove redundant knownTestsPageIndex reset at route registration (already reset in stop()) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 840ee2a commit bcae91f

3 files changed

Lines changed: 286 additions & 39 deletions

File tree

integration-tests/ci-visibility-intake.js

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ let correlationId = DEFAULT_CORRELATION_ID
5252
let knownTests = DEFAULT_KNOWN_TESTS
5353
let knownTestsStatusCode = DEFAULT_KNOWN_TESTS_RESPONSE_STATUS
5454
let waitingTime = 0
55+
let knownTestsPageIndex = 0
5556
let testManagementResponse = DEFAULT_TEST_MANAGEMENT_TESTS
5657
let testManagementResponseStatusCode = DEFAULT_TEST_MANAGEMENT_TESTS_RESPONSE_STATUS
5758

@@ -247,18 +248,34 @@ class FakeCiVisIntake extends FakeAgent {
247248
], (req, res) => {
248249
// The endpoint returns compressed data if 'accept-encoding' is set to 'gzip'
249250
const isGzip = req.headers['accept-encoding'] === 'gzip'
250-
const data = JSON.stringify({
251-
data: {
252-
attributes: {
253-
tests: knownTests,
251+
252+
let responseData
253+
if (Array.isArray(knownTests)) {
254+
// Paginated mode: knownTests is an array of page responses
255+
const page = knownTestsPageIndex < knownTests.length ? knownTests[knownTestsPageIndex] : null
256+
knownTestsPageIndex++
257+
if (page) {
258+
responseData = JSON.stringify(page)
259+
} else {
260+
res.status(404).send('')
261+
return
262+
}
263+
} else {
264+
// Legacy single-response mode
265+
responseData = JSON.stringify({
266+
data: {
267+
attributes: {
268+
tests: knownTests,
269+
},
254270
},
255-
},
256-
})
271+
})
272+
}
273+
257274
res.setHeader('content-type', 'application/json')
258275
if (isGzip) {
259276
res.setHeader('content-encoding', 'gzip')
260277
}
261-
res.status(knownTestsStatusCode).send(isGzip ? zlib.gzipSync(data) : data)
278+
res.status(knownTestsStatusCode).send(isGzip ? zlib.gzipSync(responseData) : responseData)
262279
this.emit('message', {
263280
headers: req.headers,
264281
url: req.url,
@@ -320,12 +337,17 @@ class FakeCiVisIntake extends FakeAgent {
320337
})
321338
}
322339

340+
resetKnownTestsPageIndex () {
341+
knownTestsPageIndex = 0
342+
}
343+
323344
stop () {
324345
settings = DEFAULT_SETTINGS
325346
settingsResponseStatusCode = 200
326347
suitesToSkip = DEFAULT_SUITES_TO_SKIP
327348
gitUploadStatus = DEFAULT_GIT_UPLOAD_STATUS
328349
knownTestsStatusCode = DEFAULT_KNOWN_TESTS_RESPONSE_STATUS
350+
knownTestsPageIndex = 0
329351
infoResponse = DEFAULT_INFO_RESPONSE
330352
testManagementResponseStatusCode = DEFAULT_TEST_MANAGEMENT_TESTS_RESPONSE_STATUS
331353
testManagementResponse = DEFAULT_TEST_MANAGEMENT_TESTS

packages/dd-trace/src/ci-visibility/early-flake-detection/get-known-tests.js

Lines changed: 103 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,36 @@ const {
1717

1818
const { getNumFromKnownTests } = require('../../plugins/util/test')
1919

20+
const MAX_KNOWN_TESTS_PAGES = 10_000
21+
22+
/**
23+
* Deep-merges page tests into aggregate.
24+
* Structure: { module: { suite: [testName, ...] } }
25+
*/
26+
function mergeKnownTests (aggregate, page) {
27+
if (!page) return aggregate
28+
if (!aggregate) return page
29+
30+
for (const [moduleName, suites] of Object.entries(page)) {
31+
if (!suites) continue
32+
33+
if (!aggregate[moduleName]) {
34+
aggregate[moduleName] = suites
35+
continue
36+
}
37+
38+
for (const [suiteName, tests] of Object.entries(suites)) {
39+
if (!tests || tests.length === 0) continue
40+
41+
aggregate[moduleName][suiteName] = aggregate[moduleName][suiteName]
42+
? [...aggregate[moduleName][suiteName], ...tests]
43+
: tests
44+
}
45+
}
46+
47+
return aggregate
48+
}
49+
2050
function getKnownTests ({
2151
url,
2252
isEvpProxy,
@@ -59,53 +89,94 @@ function getKnownTests ({
5989
options.headers['dd-api-key'] = apiKey
6090
}
6191

62-
const data = JSON.stringify({
63-
data: {
64-
id: id().toString(10),
65-
type: 'ci_app_libraries_tests_request',
66-
attributes: {
67-
configurations: {
68-
'os.platform': osPlatform,
69-
'os.version': osVersion,
70-
'os.architecture': osArchitecture,
71-
'runtime.name': runtimeName,
72-
'runtime.version': runtimeVersion,
73-
custom,
74-
},
75-
service,
76-
env,
77-
repository_url: repositoryUrl,
78-
sha,
79-
},
80-
},
81-
})
92+
const configurations = {
93+
'os.platform': osPlatform,
94+
'os.version': osVersion,
95+
'os.architecture': osArchitecture,
96+
'runtime.name': runtimeName,
97+
'runtime.version': runtimeVersion,
98+
custom,
99+
}
82100

83101
incrementCountMetric(TELEMETRY_KNOWN_TESTS)
84102

85103
const startTime = Date.now()
104+
let aggregateTests = null
105+
let totalResponseBytes = 0
106+
let pageNumber = 0
107+
108+
function fetchPage (pageState) {
109+
pageNumber++
110+
111+
if (pageNumber > MAX_KNOWN_TESTS_PAGES) {
112+
log.error('Known tests pagination exceeded maximum of %d pages. Aborting.', MAX_KNOWN_TESTS_PAGES)
113+
distributionMetric(TELEMETRY_KNOWN_TESTS_MS, {}, Date.now() - startTime)
114+
return done(new Error(`Known tests pagination exceeded maximum of ${MAX_KNOWN_TESTS_PAGES} pages`))
115+
}
116+
117+
const pageInfo = pageState ? { page_state: pageState } : {}
118+
119+
const data = JSON.stringify({
120+
data: {
121+
id: id().toString(10),
122+
type: 'ci_app_libraries_tests_request',
123+
attributes: {
124+
configurations,
125+
service,
126+
env,
127+
repository_url: repositoryUrl,
128+
sha,
129+
page_info: pageInfo,
130+
},
131+
},
132+
})
133+
134+
request(data, options, (err, res, statusCode) => {
135+
if (err) {
136+
distributionMetric(TELEMETRY_KNOWN_TESTS_MS, {}, Date.now() - startTime)
137+
incrementCountMetric(TELEMETRY_KNOWN_TESTS_ERRORS, { statusCode })
138+
return done(err)
139+
}
86140

87-
request(data, options, (err, res, statusCode) => {
88-
distributionMetric(TELEMETRY_KNOWN_TESTS_MS, {}, Date.now() - startTime)
89-
if (err) {
90-
incrementCountMetric(TELEMETRY_KNOWN_TESTS_ERRORS, { statusCode })
91-
done(err)
92-
} else {
93141
try {
94-
const { data: { attributes: { tests: knownTests } } } = JSON.parse(res)
142+
totalResponseBytes += res.length
143+
144+
const { data: { attributes } } = JSON.parse(res)
145+
const { tests: pageTests, page_info: responsePageInfo } = attributes
95146

96-
const numTests = getNumFromKnownTests(knownTests)
147+
aggregateTests = mergeKnownTests(aggregateTests, pageTests)
148+
149+
// Check if there are more pages
150+
if (responsePageInfo && responsePageInfo.has_next) {
151+
if (!responsePageInfo.cursor) {
152+
log.error(
153+
'Known tests response has has_next=true but no cursor on page %d. Aborting pagination.', pageNumber
154+
)
155+
distributionMetric(TELEMETRY_KNOWN_TESTS_MS, {}, Date.now() - startTime)
156+
return done(new Error('Known tests pagination: has_next=true but no cursor'))
157+
}
158+
return fetchPage(responsePageInfo.cursor)
159+
}
160+
161+
// Done — no more pages
162+
distributionMetric(TELEMETRY_KNOWN_TESTS_MS, {}, Date.now() - startTime)
163+
164+
const numTests = getNumFromKnownTests(aggregateTests)
97165

98166
distributionMetric(TELEMETRY_KNOWN_TESTS_RESPONSE_TESTS, {}, numTests)
99-
distributionMetric(TELEMETRY_KNOWN_TESTS_RESPONSE_BYTES, {}, res.length)
167+
distributionMetric(TELEMETRY_KNOWN_TESTS_RESPONSE_BYTES, {}, totalResponseBytes)
100168

101169
log.debug('Number of received known tests:', numTests)
102170

103-
done(null, knownTests)
171+
done(null, aggregateTests)
104172
} catch (err) {
173+
distributionMetric(TELEMETRY_KNOWN_TESTS_MS, {}, Date.now() - startTime)
105174
done(err)
106175
}
107-
}
108-
})
176+
})
177+
}
178+
179+
fetchPage(null)
109180
}
110181

111182
module.exports = { getKnownTests }

packages/dd-trace/test/ci-visibility/exporters/ci-visibility-exporter.spec.js

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -849,6 +849,160 @@ describe('CI Visibility Exporter', () => {
849849
done()
850850
})
851851
})
852+
853+
it('should handle paginated responses and merge tests across pages', (done) => {
854+
const scope = nock(url)
855+
.post('/api/v2/ci/libraries/tests')
856+
.reply(200, JSON.stringify({
857+
data: {
858+
attributes: {
859+
tests: {
860+
jest: {
861+
suite1: ['test1'],
862+
},
863+
},
864+
page_info: {
865+
cursor: 'cursor_page2',
866+
has_next: true,
867+
size: 1,
868+
},
869+
},
870+
},
871+
}))
872+
.post('/api/v2/ci/libraries/tests')
873+
.reply(200, JSON.stringify({
874+
data: {
875+
attributes: {
876+
tests: {
877+
jest: {
878+
suite1: ['test2'],
879+
suite2: ['test3'],
880+
},
881+
},
882+
page_info: {
883+
cursor: '',
884+
has_next: false,
885+
size: 2,
886+
},
887+
},
888+
},
889+
}))
890+
891+
const ciVisibilityExporter = new CiVisibilityExporter({ port })
892+
893+
ciVisibilityExporter._resolveCanUseCiVisProtocol(true)
894+
ciVisibilityExporter._libraryConfig = { isKnownTestsEnabled: true }
895+
ciVisibilityExporter.getKnownTests({}, (err, knownTests) => {
896+
assert.strictEqual(err, null)
897+
assert.deepStrictEqual(knownTests, {
898+
jest: {
899+
suite1: ['test1', 'test2'],
900+
suite2: ['test3'],
901+
},
902+
})
903+
assert.strictEqual(scope.isDone(), true)
904+
done()
905+
})
906+
})
907+
908+
it('should handle backward-compatible response without page_info', (done) => {
909+
const scope = nock(url)
910+
.post('/api/v2/ci/libraries/tests')
911+
.reply(200, JSON.stringify({
912+
data: {
913+
attributes: {
914+
tests: {
915+
jest: {
916+
suite1: ['test1'],
917+
},
918+
},
919+
},
920+
},
921+
}))
922+
923+
const ciVisibilityExporter = new CiVisibilityExporter({ port })
924+
925+
ciVisibilityExporter._resolveCanUseCiVisProtocol(true)
926+
ciVisibilityExporter._libraryConfig = { isKnownTestsEnabled: true }
927+
ciVisibilityExporter.getKnownTests({}, (err, knownTests) => {
928+
assert.strictEqual(err, null)
929+
assert.deepStrictEqual(knownTests, {
930+
jest: {
931+
suite1: ['test1'],
932+
},
933+
})
934+
assert.strictEqual(scope.isDone(), true)
935+
done()
936+
})
937+
})
938+
939+
it('should send page_info in request payload', (done) => {
940+
let firstRequestBody
941+
let secondRequestBody
942+
const scope = nock(url)
943+
.post('/api/v2/ci/libraries/tests', (body) => {
944+
firstRequestBody = body
945+
return true
946+
})
947+
.reply(200, JSON.stringify({
948+
data: {
949+
attributes: {
950+
tests: { jest: { suite1: ['test1'] } },
951+
page_info: { cursor: 'abc123', has_next: true, size: 1 },
952+
},
953+
},
954+
}))
955+
.post('/api/v2/ci/libraries/tests', (body) => {
956+
secondRequestBody = body
957+
return true
958+
})
959+
.reply(200, JSON.stringify({
960+
data: {
961+
attributes: {
962+
tests: { jest: { suite2: ['test2'] } },
963+
page_info: { cursor: '', has_next: false, size: 1 },
964+
},
965+
},
966+
}))
967+
968+
const ciVisibilityExporter = new CiVisibilityExporter({ port })
969+
970+
ciVisibilityExporter._resolveCanUseCiVisProtocol(true)
971+
ciVisibilityExporter._libraryConfig = { isKnownTestsEnabled: true }
972+
ciVisibilityExporter.getKnownTests({}, (err) => {
973+
assert.strictEqual(err, null)
974+
assert.strictEqual(scope.isDone(), true)
975+
// First request should have empty page_info
976+
assert.deepStrictEqual(firstRequestBody.data.attributes.page_info, {})
977+
// Second request should have page_state from cursor
978+
assert.deepStrictEqual(secondRequestBody.data.attributes.page_info, { page_state: 'abc123' })
979+
done()
980+
})
981+
})
982+
983+
it('should return error when has_next is true but cursor is missing', (done) => {
984+
const scope = nock(url)
985+
.post('/api/v2/ci/libraries/tests')
986+
.reply(200, JSON.stringify({
987+
data: {
988+
attributes: {
989+
tests: { jest: { suite1: ['test1'] } },
990+
page_info: { has_next: true, size: 1 },
991+
},
992+
},
993+
}))
994+
995+
const ciVisibilityExporter = new CiVisibilityExporter({ port })
996+
997+
ciVisibilityExporter._resolveCanUseCiVisProtocol(true)
998+
ciVisibilityExporter._libraryConfig = { isKnownTestsEnabled: true }
999+
ciVisibilityExporter.getKnownTests({}, (err) => {
1000+
assert.notStrictEqual(err, null)
1001+
assert.match(err.message, /has_next=true but no cursor/)
1002+
assert.strictEqual(scope.isDone(), true)
1003+
done()
1004+
})
1005+
})
8521006
})
8531007
})
8541008

0 commit comments

Comments
 (0)