@elisra-devops/docgen-data-provider 1.136.6 → 1.137.0

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', () => {
@@ -276,6 +276,50 @@ describe('MangementDataProvider', () => {
276
276
  );
277
277
  });
278
278
  });
279
+
280
+ describe('GetIdentityById', () => {
281
+ it('should call getItemContent with the exact documented URL shape', async () => {
282
+ const mockResponse = {
283
+ count: 1,
284
+ value: [{ id: 'id-1', properties: { Domain: { $value: 'GALAXY' }, Account: { $value: 'EdenS' } } }],
285
+ };
286
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(mockResponse);
287
+
288
+ const result = await managementDataProvider.GetIdentityById('69c3a88a-e668-4ed9-b9e8-9c2eb96fc30e');
289
+
290
+ expect(TFSServices.getItemContent).toHaveBeenCalledWith(
291
+ `${mockOrgUrl}_apis/identities?identityIds=69c3a88a-e668-4ed9-b9e8-9c2eb96fc30e&queryMembership=None&api-version=6.0`,
292
+ mockToken,
293
+ 'get',
294
+ null,
295
+ null,
296
+ false
297
+ );
298
+ expect(result).toEqual(mockResponse);
299
+ });
300
+
301
+ it('should URL-encode an identity id that needs escaping', async () => {
302
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce({ count: 0, value: [] });
303
+
304
+ await managementDataProvider.GetIdentityById('id with spaces');
305
+
306
+ expect(TFSServices.getItemContent).toHaveBeenCalledWith(
307
+ `${mockOrgUrl}_apis/identities?identityIds=id%20with%20spaces&queryMembership=None&api-version=6.0`,
308
+ mockToken,
309
+ 'get',
310
+ null,
311
+ null,
312
+ false
313
+ );
314
+ });
315
+
316
+ it('should propagate an error from the API call', async () => {
317
+ const expectedError = new Error('Identities API call failed');
318
+ (TFSServices.getItemContent as jest.Mock).mockRejectedValueOnce(expectedError);
319
+
320
+ await expect(managementDataProvider.GetIdentityById('id-1')).rejects.toThrow('Identities API call failed');
321
+ });
322
+ });
279
323
  });
280
324
 
281
325
  describe('MangementDataProvider - Additional Tests', () => {
@@ -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 }],