@elisra-devops/docgen-data-provider 1.137.0 → 1.138.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.
- package/bin/modules/TicketsDataProvider.d.ts +5 -0
- package/bin/modules/TicketsDataProvider.js +112 -11
- package/bin/modules/TicketsDataProvider.js.map +1 -1
- package/bin/tests/modules/ticketsDataProvider.historical.test.js +164 -3
- package/bin/tests/modules/ticketsDataProvider.historical.test.js.map +1 -1
- package/package.json +1 -1
- package/src/modules/TicketsDataProvider.ts +151 -14
- package/src/tests/modules/ticketsDataProvider.historical.test.ts +195 -3
|
@@ -269,9 +269,22 @@ describe('TicketsDataProvider historical queries', () => {
|
|
|
269
269
|
String(call[0]).includes('api-version=5.1') &&
|
|
270
270
|
String(call[2]).toLowerCase() === 'post',
|
|
271
271
|
);
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
272
|
+
// Two chunks (200 + 5 ids), two batched passes per chunk (fields-only,
|
|
273
|
+
// then $expand=Relations-only) — never both in the same request, since
|
|
274
|
+
// ADO rejects that combination.
|
|
275
|
+
expect(batchCalls).toHaveLength(4);
|
|
276
|
+
batchCalls.forEach((call) => {
|
|
277
|
+
const payload = call[3];
|
|
278
|
+
expect(payload.fields && payload.$expand).toBeUndefined();
|
|
279
|
+
expect(payload.errorPolicy).toBe('Omit');
|
|
280
|
+
});
|
|
281
|
+
const fieldsCalls = batchCalls.filter((call) => Array.isArray(call[3]?.fields));
|
|
282
|
+
const relationsCalls = batchCalls.filter((call) => call[3]?.$expand === 'Relations');
|
|
283
|
+
expect(fieldsCalls).toHaveLength(2);
|
|
284
|
+
expect(relationsCalls).toHaveLength(2);
|
|
285
|
+
const byLength = (a: number, b: number) => a - b;
|
|
286
|
+
expect(fieldsCalls.map((call) => call[3].ids.length).sort(byLength)).toEqual([5, 200]);
|
|
287
|
+
expect(relationsCalls.map((call) => call[3].ids.length).sort(byLength)).toEqual([5, 200]);
|
|
275
288
|
});
|
|
276
289
|
|
|
277
290
|
it('GetHistoricalQueryResults falls back to per-item retrieval when workitemsbatch fails', async () => {
|
|
@@ -873,4 +886,183 @@ describe('TicketsDataProvider historical queries', () => {
|
|
|
873
886
|
expect.objectContaining({ baselineCount: 1, compareToCount: 1, totalDistinct: 1 }),
|
|
874
887
|
);
|
|
875
888
|
});
|
|
889
|
+
|
|
890
|
+
it('CompareHistoricalQueryResults merges relations from the separate $expand pass into relatedLinkCount', async () => {
|
|
891
|
+
const baselineIso = '2026-01-05T00:00:00.000Z';
|
|
892
|
+
const compareIso = '2026-01-10T00:00:00.000Z';
|
|
893
|
+
|
|
894
|
+
// Fields-only and relations-only responses are disjoint (as ADO actually
|
|
895
|
+
// returns them for each pass) to prove the merge-by-id, not just that a
|
|
896
|
+
// duplicated mock happens to satisfy both reads.
|
|
897
|
+
(TFSServices.getItemContent as jest.Mock).mockImplementation(
|
|
898
|
+
async (url: string, _pat: string, method?: string, data?: any) => {
|
|
899
|
+
if (url.includes('/_apis/wit/queries/q-merge-relations') && url.includes('api-version=7.1')) {
|
|
900
|
+
return { name: 'Merge Relations Q', wiql: 'SELECT [System.Id] FROM WorkItems' };
|
|
901
|
+
}
|
|
902
|
+
if (url.includes('/_apis/wit/wiql?') && method === 'post') {
|
|
903
|
+
return { workItems: [{ id: 501 }] };
|
|
904
|
+
}
|
|
905
|
+
if (url.includes('/_apis/wit/workitemsbatch') && method === 'post') {
|
|
906
|
+
expect(data.fields && data.$expand).toBeUndefined();
|
|
907
|
+
expect(data.errorPolicy).toBe('Omit');
|
|
908
|
+
const isRelationsPass = data.$expand === 'Relations';
|
|
909
|
+
const isBaseline = data.asOf === baselineIso;
|
|
910
|
+
if (isRelationsPass) {
|
|
911
|
+
return {
|
|
912
|
+
value: [{ id: 501, relations: isBaseline ? [{ id: 'l-1' }] : [{ id: 'l-1' }, { id: 'l-2' }] }],
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
return {
|
|
916
|
+
value: [
|
|
917
|
+
{
|
|
918
|
+
id: 501,
|
|
919
|
+
rev: isBaseline ? 1 : 2,
|
|
920
|
+
fields: {
|
|
921
|
+
'System.WorkItemType': 'Test Case',
|
|
922
|
+
'System.Title': 'Merge Case',
|
|
923
|
+
'System.State': 'Active',
|
|
924
|
+
'Microsoft.VSTS.TCM.Steps': '<steps>same</steps>',
|
|
925
|
+
'System.ChangedDate': isBaseline ? baselineIso : compareIso,
|
|
926
|
+
},
|
|
927
|
+
},
|
|
928
|
+
],
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
throw new Error(`unexpected URL: ${url}`);
|
|
932
|
+
},
|
|
933
|
+
);
|
|
934
|
+
|
|
935
|
+
const result = await provider.CompareHistoricalQueryResults(
|
|
936
|
+
'q-merge-relations',
|
|
937
|
+
project,
|
|
938
|
+
baselineIso,
|
|
939
|
+
compareIso,
|
|
940
|
+
);
|
|
941
|
+
|
|
942
|
+
const row = result.rows.find((r: any) => r.id === 501);
|
|
943
|
+
expect(row?.compareStatus).toBe('Changed');
|
|
944
|
+
expect(row?.changedFields).toEqual(['Related Link Count']);
|
|
945
|
+
const diff = row?.differences.find((d: any) => d.field === 'Related Link Count');
|
|
946
|
+
expect(diff).toEqual({ field: 'Related Link Count', baseline: '1', compareTo: '2' });
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
it('GetHistoricalQueryResults treats a workitemsbatch errorPolicy Omit as skipped without a per-item fallback', async () => {
|
|
950
|
+
const asOfIso = '2026-01-15T00:00:00.000Z';
|
|
951
|
+
|
|
952
|
+
(TFSServices.getItemContent as jest.Mock).mockImplementation(
|
|
953
|
+
async (url: string, _pat: string, method?: string, data?: any) => {
|
|
954
|
+
if (url.includes('/_apis/wit/queries/q-omit') && url.includes('api-version=7.1')) {
|
|
955
|
+
return { name: 'Omit Q', wiql: 'SELECT [System.Id] FROM WorkItems' };
|
|
956
|
+
}
|
|
957
|
+
if (url.includes('/_apis/wit/wiql?') && method === 'post') {
|
|
958
|
+
return { workItems: [{ id: 601 }, { id: 602 }] };
|
|
959
|
+
}
|
|
960
|
+
if (url.includes('/_apis/wit/workitemsbatch') && method === 'post') {
|
|
961
|
+
expect(data.errorPolicy).toBe('Omit');
|
|
962
|
+
// Work item 602 is inaccessible/deleted and is omitted by ADO from
|
|
963
|
+
// `value` rather than failing the whole batch.
|
|
964
|
+
if (data.$expand === 'Relations') {
|
|
965
|
+
return { value: [{ id: 601, relations: [] }] };
|
|
966
|
+
}
|
|
967
|
+
return {
|
|
968
|
+
value: [
|
|
969
|
+
{
|
|
970
|
+
id: 601,
|
|
971
|
+
rev: 1,
|
|
972
|
+
fields: {
|
|
973
|
+
'System.WorkItemType': 'Bug',
|
|
974
|
+
'System.Title': 'Present Bug',
|
|
975
|
+
'System.State': 'Active',
|
|
976
|
+
'System.ChangedDate': asOfIso,
|
|
977
|
+
},
|
|
978
|
+
},
|
|
979
|
+
],
|
|
980
|
+
};
|
|
981
|
+
}
|
|
982
|
+
// A call here would mean the per-item fallback ran despite the batch
|
|
983
|
+
// succeeding, which defeats the point of errorPolicy: 'Omit'.
|
|
984
|
+
throw new Error(`unexpected URL: ${url}`);
|
|
985
|
+
},
|
|
986
|
+
);
|
|
987
|
+
|
|
988
|
+
const result = await provider.GetHistoricalQueryResults('q-omit', project, asOfIso);
|
|
989
|
+
|
|
990
|
+
expect(result.total).toBe(1);
|
|
991
|
+
expect(result.skippedWorkItemsCount).toBe(1);
|
|
992
|
+
expect(result.rows.map((row: any) => row.id)).toEqual([601]);
|
|
993
|
+
expect(
|
|
994
|
+
(logger.warn as jest.Mock).mock.calls.some((call) => String(call[0]).includes('omitted 1')),
|
|
995
|
+
).toBe(true);
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
it('GetHistoricalQueryResults drops only the specific field ADO names in TF51535, keeping Custom.TestPhase when only Elisra.TestPhase is undefined for this project', async () => {
|
|
999
|
+
const asOfIso = '2026-02-01T00:00:00.000Z';
|
|
1000
|
+
// 205 ids -> 2 chunks (200 + 5), both dispatched with the full field list
|
|
1001
|
+
// near-simultaneously — this proves the retry is decided per-call, not
|
|
1002
|
+
// from the shared override flag, since both chunks fail before either
|
|
1003
|
+
// one sets it.
|
|
1004
|
+
const allIds = Array.from({ length: 205 }, (_, idx) => idx + 1);
|
|
1005
|
+
|
|
1006
|
+
(TFSServices.getItemContent as jest.Mock).mockImplementation(
|
|
1007
|
+
async (url: string, _pat: string, method?: string, data?: any) => {
|
|
1008
|
+
if (url.includes('/_apis/wit/queries/q-unknown-field') && url.includes('api-version=7.1')) {
|
|
1009
|
+
return { name: 'Unknown Field Q', wiql: 'SELECT [System.Id] FROM WorkItems' };
|
|
1010
|
+
}
|
|
1011
|
+
if (url.includes('/_apis/wit/wiql?') && method === 'post') {
|
|
1012
|
+
return { workItems: allIds.map((id) => ({ id })) };
|
|
1013
|
+
}
|
|
1014
|
+
if (url.includes('/_apis/wit/workitemsbatch') && method === 'post') {
|
|
1015
|
+
if (data?.$expand === 'Relations') {
|
|
1016
|
+
return {
|
|
1017
|
+
value: (Array.isArray(data?.ids) ? data.ids : []).map((id: number) => ({ id, relations: [] })),
|
|
1018
|
+
};
|
|
1019
|
+
}
|
|
1020
|
+
if (Array.isArray(data?.fields) && data.fields.includes('Elisra.TestPhase')) {
|
|
1021
|
+
throw {
|
|
1022
|
+
response: {
|
|
1023
|
+
status: 400,
|
|
1024
|
+
data: { message: "TF51535: Cannot find field 'Elisra.TestPhase'." },
|
|
1025
|
+
},
|
|
1026
|
+
};
|
|
1027
|
+
}
|
|
1028
|
+
// Custom.TestPhase is the real field on this project and must
|
|
1029
|
+
// still be requested — only the unrecognized alias should be
|
|
1030
|
+
// dropped, not every optional field.
|
|
1031
|
+
expect(data.fields).toEqual(expect.arrayContaining(['Custom.TestPhase']));
|
|
1032
|
+
expect(data.fields).not.toEqual(expect.arrayContaining(['Elisra.TestPhase']));
|
|
1033
|
+
return {
|
|
1034
|
+
value: (Array.isArray(data?.ids) ? data.ids : []).map((id: number) => ({
|
|
1035
|
+
id,
|
|
1036
|
+
rev: 1,
|
|
1037
|
+
fields: {
|
|
1038
|
+
'System.WorkItemType': 'Test Case',
|
|
1039
|
+
'System.Title': `Test ${id}`,
|
|
1040
|
+
'System.State': 'Active',
|
|
1041
|
+
'System.ChangedDate': asOfIso,
|
|
1042
|
+
'Custom.TestPhase': 'FAT',
|
|
1043
|
+
},
|
|
1044
|
+
})),
|
|
1045
|
+
};
|
|
1046
|
+
}
|
|
1047
|
+
throw new Error(`unexpected URL: ${url}`);
|
|
1048
|
+
},
|
|
1049
|
+
);
|
|
1050
|
+
|
|
1051
|
+
const result = await provider.GetHistoricalQueryResults('q-unknown-field', project, asOfIso);
|
|
1052
|
+
|
|
1053
|
+
// Every id came back — nothing fell to the 2xN per-item fallback, which
|
|
1054
|
+
// would have thrown on any call not covered by the handler above.
|
|
1055
|
+
expect(result.total).toBe(205);
|
|
1056
|
+
const fieldsCallsWithElisraAlias = (TFSServices.getItemContent as jest.Mock).mock.calls.filter(
|
|
1057
|
+
(call) => Array.isArray(call[3]?.fields) && call[3].fields.includes('Elisra.TestPhase'),
|
|
1058
|
+
);
|
|
1059
|
+
// Both chunks start with the full field list before the override is set,
|
|
1060
|
+
// so both legitimately fail once and both must retry — not just one.
|
|
1061
|
+
expect(fieldsCallsWithElisraAlias).toHaveLength(2);
|
|
1062
|
+
expect(
|
|
1063
|
+
(logger.warn as jest.Mock).mock.calls.some((call) =>
|
|
1064
|
+
String(call[0]).includes("field 'Elisra.TestPhase' not defined for this project"),
|
|
1065
|
+
),
|
|
1066
|
+
).toBe(true);
|
|
1067
|
+
});
|
|
876
1068
|
});
|