@elisra-devops/docgen-data-provider 1.136.5 → 1.136.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -115,20 +115,45 @@ export default class TestDataProvider {
115
115
  return descriptions;
116
116
  }
117
117
 
118
+ // DFS pre-order flatten of a suite payload that may be a nested tree (asTreeView=true),
119
+ // a flat list whose elements also carry populated `children[]`, or a plain flat list.
120
+ // The `seen` guard means a suite is only emitted once even if it appears both in the
121
+ // top-level array and nested under a `children` array, so tree order always wins when
122
+ // available, with no duplicates and no change in behavior when `children` is absent.
123
+ private flattenSuitesTreeOrder(suites: any[]): any[] {
124
+ const flat: any[] = [];
125
+ const seen = new Set<any>();
126
+
127
+ const visit = (suite: any) => {
128
+ if (!suite || seen.has(suite.id)) return;
129
+ seen.add(suite.id);
130
+ const { children, ...rest } = suite;
131
+ flat.push(rest);
132
+ if (Array.isArray(children)) {
133
+ children.forEach(visit);
134
+ }
135
+ };
136
+
137
+ suites.forEach(visit);
138
+ return flat;
139
+ }
140
+
118
141
  private async normalizeAndEnrichSuitesResponse(project: string, data: any): Promise<any> {
119
- const suites = Array.isArray(data?.testSuites)
142
+ const rawSuites = Array.isArray(data?.testSuites)
120
143
  ? data.testSuites
121
144
  : Array.isArray(data?.value)
122
145
  ? data.value
123
146
  : Array.isArray(data)
124
147
  ? data
125
148
  : [];
149
+ const suites = this.flattenSuitesTreeOrder(rawSuites);
126
150
 
127
- const mapped = suites.map((suite: any) => ({
151
+ const mapped = suites.map((suite: any, index: number) => ({
128
152
  ...suite,
129
153
  title: suite?.title || suite?.name || '',
130
154
  parentSuiteId: suite?.parentSuiteId ?? suite?.parentSuite?.id ?? 0,
131
155
  description: this.getSuiteDescription(suite),
156
+ treeOrder: index,
132
157
  }));
133
158
 
134
159
  const missingDescriptionIds = mapped
@@ -190,7 +215,7 @@ export default class TestDataProvider {
190
215
 
191
216
  // Bearer branch — follow x-ms-continuationtoken until the server stops returning it.
192
217
  // Doc: https://learn.microsoft.com/en-us/rest/api/azure/devops/testplan/test-suites/get-test-suites-for-plan?view=azure-devops-rest-7.0
193
- const base = `${this.joinOrgProject(project)}/_apis/testplan/Plans/${planid}/suites?expand=children&api-version=7.0`;
218
+ const base = `${this.joinOrgProject(project)}/_apis/testplan/Plans/${planid}/suites?expand=children&asTreeView=true&api-version=7.0`;
194
219
  const cacheKey = `${base}__all_pages`;
195
220
 
196
221
  if (this.cache.has(cacheKey)) {
@@ -277,6 +302,14 @@ export default class TestDataProvider {
277
302
  }
278
303
  }
279
304
 
305
+ // Multiple hierarchies must be emitted in tree order, not in the order the
306
+ // caller happened to list suiteIdsFilter.
307
+ topLevelSuites.sort((a, b) => {
308
+ const orderA = suiteMap.get(a)?.treeOrder ?? suiteMap.get(parseInt(a))?.treeOrder ?? 0;
309
+ const orderB = suiteMap.get(b)?.treeOrder ?? suiteMap.get(parseInt(b))?.treeOrder ?? 0;
310
+ return orderA - orderB;
311
+ });
312
+
280
313
  if (topLevelSuites.length === 0) {
281
314
  // Fallback: use first suite if no clear top-level suites found
282
315
  const selectedSuiteId = suiteIdsFilter[0].toString();
@@ -4,9 +4,6 @@ import { Query, Workitem } from '../../models/tfs-data';
4
4
  describe('Helper', () => {
5
5
  beforeEach(() => {
6
6
  // Reset static state before each test
7
- Helper.suitList = [];
8
- Helper.level = 1;
9
- Helper.first = true;
10
7
  Helper.levelList = [];
11
8
  });
12
9
 
@@ -241,6 +238,75 @@ describe('Helper', () => {
241
238
  // Assert
242
239
  expect(result.length).toBe(5);
243
240
  });
241
+
242
+ it('should emit siblings in array (tree) order even when their ids are not ascending', () => {
243
+ // Arrange - array order mirrors a manually reordered ADO tree: D, B, A, C
244
+ const suits = [
245
+ { id: '1', title: 'Root', parentSuiteId: 0 },
246
+ { id: '40', title: 'Suite D', parentSuiteId: '1' },
247
+ { id: '20', title: 'Suite B', parentSuiteId: '1' },
248
+ { id: '10', title: 'Suite A', parentSuiteId: '1' },
249
+ { id: '30', title: 'Suite C', parentSuiteId: '1' },
250
+ ];
251
+
252
+ // Act
253
+ const result = Helper.findSuitesRecursive(mockPlanId, mockUrl, mockProject, suits, '1', true);
254
+
255
+ // Assert
256
+ expect(result.map((s) => s.name)).toEqual(['Suite D', 'Suite B', 'Suite A', 'Suite C']);
257
+ });
258
+
259
+ it('should emit the selected nested suite first even when it appears last in the source array', () => {
260
+ // Arrange
261
+ const suits = [
262
+ { id: '3', title: 'Grandchild Suite', parentSuiteId: '2' },
263
+ { id: '1', title: 'Root Suite', parentSuiteId: 0 },
264
+ { id: '2', title: 'Child Suite 1', parentSuiteId: '1' },
265
+ ];
266
+
267
+ // Act
268
+ const result = Helper.findSuitesRecursive(mockPlanId, mockUrl, mockProject, suits, '2', true);
269
+
270
+ // Assert
271
+ expect(result.map((s) => s.name)).toEqual(['Child Suite 1', 'Grandchild Suite']);
272
+ });
273
+
274
+ it('should compute levels correctly regardless of source array position', () => {
275
+ // Arrange - grandchild appears before its parent in the array
276
+ const suits = [
277
+ { id: '3', title: 'Level 2', parentSuiteId: '2' },
278
+ { id: '1', title: 'Root', parentSuiteId: 0 },
279
+ { id: '2', title: 'Level 1', parentSuiteId: '1' },
280
+ ];
281
+
282
+ // Act
283
+ const result = Helper.findSuitesRecursive(mockPlanId, mockUrl, mockProject, suits, '1', true);
284
+
285
+ // Assert
286
+ expect(result.find((s) => s.name === 'Level 1')?.level).toBe(1);
287
+ expect(result.find((s) => s.name === 'Level 2')?.level).toBe(2);
288
+ });
289
+
290
+ it('should not leak state between sequential calls', () => {
291
+ // Arrange
292
+ const suitsA = [
293
+ { id: '1', title: 'Root A', parentSuiteId: 0 },
294
+ { id: '2', title: 'Child A', parentSuiteId: '1' },
295
+ ];
296
+ const suitsB = [
297
+ { id: '10', title: 'Root B', parentSuiteId: 0 },
298
+ { id: '20', title: 'Child B1', parentSuiteId: '10' },
299
+ { id: '30', title: 'Child B2', parentSuiteId: '10' },
300
+ ];
301
+
302
+ // Act
303
+ const resultA = Helper.findSuitesRecursive(mockPlanId, mockUrl, mockProject, suitsA, '1', true);
304
+ const resultB = Helper.findSuitesRecursive(mockPlanId, mockUrl, mockProject, suitsB, '10', true);
305
+
306
+ // Assert - resultB is unaffected by resultA's traversal
307
+ expect(resultA.map((s) => s.name)).toEqual(['Child A']);
308
+ expect(resultB.map((s) => s.name)).toEqual(['Child B1', 'Child B2']);
309
+ });
244
310
  });
245
311
 
246
312
  describe('LevelBuilder', () => {
@@ -951,23 +951,28 @@ describe('PipelinesDataProvider', () => {
951
951
  });
952
952
 
953
953
  describe('GetRecentReleaseArtifactInfo', () => {
954
- it('should return empty array when no releases exist', async () => {
954
+ it('should return empty array when release response has no artifacts', async () => {
955
955
  // Arrange
956
956
  const projectName = 'project1';
957
- const mockResponse = { value: [] };
958
- (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(mockResponse);
957
+ const releaseId = 123;
958
+ const mockReleaseResponse = { artifacts: [] };
959
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(mockReleaseResponse);
959
960
 
960
961
  // Act
961
- const result = await pipelinesDataProvider.GetRecentReleaseArtifactInfo(projectName);
962
+ const result = await pipelinesDataProvider.GetRecentReleaseArtifactInfo(projectName, releaseId);
962
963
 
963
964
  // Assert
965
+ expect(TFSServices.getItemContent).toHaveBeenCalledWith(
966
+ expect.stringContaining(`releases/${releaseId}`),
967
+ mockToken
968
+ );
964
969
  expect(result).toEqual([]);
965
970
  });
966
971
 
967
- it('should return artifact info from most recent release', async () => {
972
+ it('should return artifact info for the specified release', async () => {
968
973
  // Arrange
969
974
  const projectName = 'project1';
970
- const mockReleasesResponse = { value: [{ id: 123 }] };
975
+ const releaseId = 123;
971
976
  const mockReleaseResponse = {
972
977
  artifacts: [
973
978
  {
@@ -978,16 +983,44 @@ describe('PipelinesDataProvider', () => {
978
983
  },
979
984
  ],
980
985
  };
981
- (TFSServices.getItemContent as jest.Mock)
982
- .mockResolvedValueOnce(mockReleasesResponse)
983
- .mockResolvedValueOnce(mockReleaseResponse);
986
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(mockReleaseResponse);
984
987
 
985
988
  // Act
986
- const result = await pipelinesDataProvider.GetRecentReleaseArtifactInfo(projectName);
989
+ const result = await pipelinesDataProvider.GetRecentReleaseArtifactInfo(projectName, releaseId);
987
990
 
988
991
  // Assert
989
992
  expect(result).toEqual([{ artifactName: 'artifact1', artifactVersion: '1.0.0' }]);
990
993
  });
994
+
995
+ it('should return empty array without calling the API when releaseId is invalid', async () => {
996
+ // Arrange
997
+ const projectName = 'project1';
998
+
999
+ // Act
1000
+ const resultForZero = await pipelinesDataProvider.GetRecentReleaseArtifactInfo(projectName, 0);
1001
+ const resultForNaN = await pipelinesDataProvider.GetRecentReleaseArtifactInfo(projectName, NaN);
1002
+ const resultForNegative = await pipelinesDataProvider.GetRecentReleaseArtifactInfo(projectName, -5);
1003
+
1004
+ // Assert
1005
+ expect(resultForZero).toEqual([]);
1006
+ expect(resultForNaN).toEqual([]);
1007
+ expect(resultForNegative).toEqual([]);
1008
+ expect(TFSServices.getItemContent).not.toHaveBeenCalled();
1009
+ });
1010
+
1011
+ it('should return empty array when release response has no artifacts field at all', async () => {
1012
+ // Arrange
1013
+ const projectName = 'project1';
1014
+ const releaseId = 123;
1015
+ const mockReleaseResponse = { id: releaseId, name: 'Release-1' };
1016
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(mockReleaseResponse);
1017
+
1018
+ // Act
1019
+ const result = await pipelinesDataProvider.GetRecentReleaseArtifactInfo(projectName, releaseId);
1020
+
1021
+ // Assert
1022
+ expect(result).toEqual([]);
1023
+ });
991
1024
  });
992
1025
 
993
1026
  describe('getPipelineResourcePipelinesFromObject', () => {
@@ -326,7 +326,7 @@ describe('TestDataProvider', () => {
326
326
  const result = await bearerProvider.GetTestSuitesForPlan(mockProject, mockPlanId);
327
327
 
328
328
  expect(TFSServices.getItemContentWithHeaders).toHaveBeenCalledWith(
329
- `${mockOrgUrl.replace(/\/+$/, '')}/${mockProject}/_apis/testplan/Plans/${mockPlanId}/suites?expand=children&api-version=7.0`,
329
+ `${mockOrgUrl.replace(/\/+$/, '')}/${mockProject}/_apis/testplan/Plans/${mockPlanId}/suites?expand=children&asTreeView=true&api-version=7.0`,
330
330
  mockBearerToken,
331
331
  'get',
332
332
  {},
@@ -368,7 +368,7 @@ describe('TestDataProvider', () => {
368
368
  const result = await bearerProvider.GetTestSuitesForPlan(mockProject, mockPlanId);
369
369
 
370
370
  expect(TFSServices.getItemContentWithHeaders).toHaveBeenCalledWith(
371
- `${mockOrgUrl.replace(/\/+$/, '')}/${mockProject}/_apis/testplan/Plans/${mockPlanId}/suites?expand=children&api-version=7.0`,
371
+ `${mockOrgUrl.replace(/\/+$/, '')}/${mockProject}/_apis/testplan/Plans/${mockPlanId}/suites?expand=children&asTreeView=true&api-version=7.0`,
372
372
  mockBearerToken,
373
373
  'get',
374
374
  {},
@@ -407,6 +407,7 @@ describe('TestDataProvider', () => {
407
407
  const calledUrl = (TFSServices.getItemContentWithHeaders as jest.Mock).mock.calls[0][0] as string;
408
408
  expect(calledUrl).toContain(`/_apis/testplan/Plans/${mockPlanId}/suites`);
409
409
  expect(calledUrl).toContain('expand=children');
410
+ expect(calledUrl).toContain('asTreeView=true');
410
411
  expect(calledUrl).toContain('api-version=7.0');
411
412
  expect(calledUrl).not.toContain('includeChildren');
412
413
  });
@@ -480,6 +481,82 @@ describe('TestDataProvider', () => {
480
481
  expect(callCount).toBeGreaterThan(0);
481
482
  expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('reached MAX_PAGES'));
482
483
  });
484
+
485
+ it('should flatten a nested asTreeView=true payload into DFS pre-order', async () => {
486
+ const bearerProvider = new TestDataProvider(mockOrgUrl, mockBearerToken);
487
+ const treePayload = {
488
+ value: [
489
+ {
490
+ id: 1,
491
+ name: 'Root',
492
+ children: [
493
+ { id: 20, name: 'Suite B', parentSuite: { id: 1 } },
494
+ {
495
+ id: 10,
496
+ name: 'Suite A',
497
+ parentSuite: { id: 1 },
498
+ children: [{ id: 11, name: 'Suite A.1', parentSuite: { id: 10 } }],
499
+ },
500
+ ],
501
+ },
502
+ ],
503
+ count: 1,
504
+ };
505
+ (TFSServices.getItemContentWithHeaders as jest.Mock).mockResolvedValueOnce({
506
+ data: treePayload,
507
+ headers: {},
508
+ });
509
+
510
+ const result = await bearerProvider.GetTestSuitesForPlan(mockProject, mockPlanId);
511
+
512
+ expect(result.testSuites.map((s: any) => s.id)).toEqual([1, 20, 10, 11]);
513
+ expect(result.testSuites.map((s: any) => s.treeOrder)).toEqual([0, 1, 2, 3]);
514
+ });
515
+
516
+ it('should derive tree order from a flat list whose elements carry populated children[], without duplicating suites', async () => {
517
+ const bearerProvider = new TestDataProvider(mockOrgUrl, mockBearerToken);
518
+ // Server ignored/does-not-support asTreeView and returned a flat, id-ordered list,
519
+ // but each suite still carries its own children[] in display order.
520
+ const flatWithChildren = {
521
+ value: [
522
+ { id: 10, name: 'Suite A', parentSuite: { id: 1 }, children: [{ id: 11, name: 'Suite A.1' }] },
523
+ { id: 11, name: 'Suite A.1', parentSuite: { id: 10 } },
524
+ { id: 1, name: 'Root', children: [{ id: 10, name: 'Suite A' }, { id: 20, name: 'Suite B' }] },
525
+ { id: 20, name: 'Suite B', parentSuite: { id: 1 } },
526
+ ],
527
+ count: 4,
528
+ };
529
+ (TFSServices.getItemContentWithHeaders as jest.Mock).mockResolvedValueOnce({
530
+ data: flatWithChildren,
531
+ headers: {},
532
+ });
533
+
534
+ const result = await bearerProvider.GetTestSuitesForPlan(mockProject, mockPlanId);
535
+
536
+ expect(result.testSuites.map((s: any) => s.id)).toEqual([10, 11, 1, 20]);
537
+ expect(result.testSuites).toHaveLength(4);
538
+ });
539
+
540
+ it('should leave order unchanged for a flat list with no children field (regression guard)', async () => {
541
+ const bearerProvider = new TestDataProvider(mockOrgUrl, mockBearerToken);
542
+ const flatPayload = {
543
+ value: [
544
+ { id: 40, name: 'Suite D' },
545
+ { id: 20, name: 'Suite B' },
546
+ { id: 10, name: 'Suite A' },
547
+ ],
548
+ count: 3,
549
+ };
550
+ (TFSServices.getItemContentWithHeaders as jest.Mock).mockResolvedValueOnce({
551
+ data: flatPayload,
552
+ headers: {},
553
+ });
554
+
555
+ const result = await bearerProvider.GetTestSuitesForPlan(mockProject, mockPlanId);
556
+
557
+ expect(result.testSuites.map((s: any) => s.id)).toEqual([40, 20, 10]);
558
+ expect(result.testSuites.map((s: any) => s.treeOrder)).toEqual([0, 1, 2]);
559
+ });
483
560
  });
484
561
 
485
562
  describe('PAT-token branch — regression guard', () => {
@@ -548,7 +625,7 @@ describe('TestDataProvider', () => {
548
625
  const result = await bearerProvider.GetTestSuiteById(mockProject, mockPlanId, mockSuiteId, true);
549
626
 
550
627
  expect(TFSServices.getItemContentWithHeaders).toHaveBeenCalledWith(
551
- `${mockOrgUrl.replace(/\/+$/, '')}/${mockProject}/_apis/testplan/Plans/${mockPlanId}/suites?expand=children&api-version=7.0`,
628
+ `${mockOrgUrl.replace(/\/+$/, '')}/${mockProject}/_apis/testplan/Plans/${mockPlanId}/suites?expand=children&asTreeView=true&api-version=7.0`,
552
629
  mockBearerToken,
553
630
  'get',
554
631
  {},
@@ -672,6 +749,31 @@ describe('TestDataProvider', () => {
672
749
  expect(res).toEqual([{ id: '10' }, { id: '11' }]);
673
750
  });
674
751
 
752
+ it('should emit multiple hierarchies in tree order, not in suiteIdsFilter order', async () => {
753
+ jest.spyOn(testDataProvider, 'GetTestSuitesForPlan').mockResolvedValueOnce({
754
+ testSuites: [
755
+ { id: 1, parentSuiteId: 0, treeOrder: 0 },
756
+ { id: 2, parentSuiteId: 0, treeOrder: 1 },
757
+ { id: 10, parentSuiteId: 1, treeOrder: 2 },
758
+ { id: 11, parentSuiteId: 2, treeOrder: 3 },
759
+ ],
760
+ } as any);
761
+
762
+ // Caller lists the second hierarchy's suite (11) before the first (10),
763
+ // but suite 10 (treeOrder 2) precedes suite 11 (treeOrder 3) in the tree.
764
+ const suiteIdsFilter = [11, 10];
765
+ const getByIdSpy = jest
766
+ .spyOn(testDataProvider, 'GetTestSuiteById')
767
+ .mockResolvedValueOnce([{ id: '10' }])
768
+ .mockResolvedValueOnce([{ id: '11' }]);
769
+
770
+ const res = await testDataProvider.GetTestSuitesByPlan(mockProject, mockPlanId, true, suiteIdsFilter);
771
+
772
+ expect(getByIdSpy).toHaveBeenNthCalledWith(1, mockProject, mockPlanId, '10', true, suiteIdsFilter);
773
+ expect(getByIdSpy).toHaveBeenNthCalledWith(2, mockProject, mockPlanId, '11', true, suiteIdsFilter);
774
+ expect(res).toEqual([{ id: '10' }, { id: '11' }]);
775
+ });
776
+
675
777
  it('should fallback to first suite when no top-level suites can be determined', async () => {
676
778
  jest.spyOn(testDataProvider, 'GetTestSuitesForPlan').mockResolvedValueOnce({
677
779
  testSuites: [{ id: 1, parentSuiteId: 0 }],