@elisra-devops/docgen-data-provider 1.138.0 → 1.140.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 +22 -3
- package/bin/modules/TicketsDataProvider.js +132 -54
- package/bin/modules/TicketsDataProvider.js.map +1 -1
- package/bin/tests/modules/ticketsDataProvider.historical.test.js +256 -42
- package/bin/tests/modules/ticketsDataProvider.historical.test.js.map +1 -1
- package/package.json +1 -1
- package/src/modules/TicketsDataProvider.ts +182 -88
- package/src/tests/modules/ticketsDataProvider.historical.test.ts +295 -43
|
@@ -946,6 +946,173 @@ describe('TicketsDataProvider historical queries', () => {
|
|
|
946
946
|
expect(diff).toEqual({ field: 'Related Link Count', baseline: '1', compareTo: '2' });
|
|
947
947
|
});
|
|
948
948
|
|
|
949
|
+
it('CompareHistoricalQueryResults preserves raw markup for rendering while comparing on canonicalized whitespace', async () => {
|
|
950
|
+
const baselineIso = '2026-02-01T00:00:00.000Z';
|
|
951
|
+
const compareIso = '2026-02-05T00:00:00.000Z';
|
|
952
|
+
|
|
953
|
+
(TFSServices.getItemContent as jest.Mock).mockImplementation(
|
|
954
|
+
async (url: string, _pat: string, method?: string, data?: any) => {
|
|
955
|
+
if (url.includes('/_apis/wit/queries/q-raw-html') && url.includes('api-version=7.1')) {
|
|
956
|
+
return { name: 'Raw Html Q', wiql: 'SELECT [System.Id] FROM WorkItems' };
|
|
957
|
+
}
|
|
958
|
+
if (url.includes('/_apis/wit/wiql?') && method === 'post') {
|
|
959
|
+
return { workItems: [{ id: 601 }, { id: 602 }] };
|
|
960
|
+
}
|
|
961
|
+
if (url.includes('/_apis/wit/workitemsbatch') && method === 'post') {
|
|
962
|
+
const isBaseline = data.asOf === baselineIso;
|
|
963
|
+
return {
|
|
964
|
+
value: [
|
|
965
|
+
{
|
|
966
|
+
id: 601,
|
|
967
|
+
rev: isBaseline ? 1 : 2,
|
|
968
|
+
fields: {
|
|
969
|
+
'System.WorkItemType': 'Requirement',
|
|
970
|
+
'System.Title': 'Whitespace-only edit',
|
|
971
|
+
'System.State': 'Active',
|
|
972
|
+
// Same content, differing only by internal whitespace: must compare as
|
|
973
|
+
// unchanged (identical to the pre-existing collapse-then-compare behavior)
|
|
974
|
+
// while the raw stored value keeps the original spacing for rendering.
|
|
975
|
+
'System.Description': isBaseline
|
|
976
|
+
? '<p>Hello world</p>'
|
|
977
|
+
: '<p>Hello\nworld</p>',
|
|
978
|
+
'System.ChangedDate': isBaseline ? baselineIso : compareIso,
|
|
979
|
+
},
|
|
980
|
+
},
|
|
981
|
+
{
|
|
982
|
+
id: 602,
|
|
983
|
+
rev: isBaseline ? 1 : 2,
|
|
984
|
+
fields: {
|
|
985
|
+
'System.WorkItemType': 'Requirement',
|
|
986
|
+
'System.Title': 'Real edit',
|
|
987
|
+
'System.State': 'Active',
|
|
988
|
+
'System.Description': isBaseline ? '<p>Hello world</p>' : '<p>Hello <b>world</b></p>',
|
|
989
|
+
'System.ChangedDate': isBaseline ? baselineIso : compareIso,
|
|
990
|
+
},
|
|
991
|
+
},
|
|
992
|
+
],
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
throw new Error(`unexpected URL: ${url}`);
|
|
996
|
+
},
|
|
997
|
+
);
|
|
998
|
+
|
|
999
|
+
const result = await provider.CompareHistoricalQueryResults('q-raw-html', project, baselineIso, compareIso);
|
|
1000
|
+
const byId = new Map<number, any>(result.rows.map((row: any) => [row.id, row]));
|
|
1001
|
+
|
|
1002
|
+
expect(byId.get(601)?.compareStatus).toBe('No changes');
|
|
1003
|
+
expect(byId.get(602)?.compareStatus).toBe('Changed');
|
|
1004
|
+
const diff = byId.get(602)?.differences.find((d: any) => d.field === 'Description');
|
|
1005
|
+
// Raw markup is preserved on the difference payload, not collapsed to plain text.
|
|
1006
|
+
expect(diff.baseline).toBe('<p>Hello world</p>');
|
|
1007
|
+
expect(diff.compareTo).toBe('<p>Hello <b>world</b></p>');
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
it('CompareHistoricalQueryResults attaches parsed Action/Expected steps for a changed test case', async () => {
|
|
1011
|
+
const baselineIso = '2026-02-10T00:00:00.000Z';
|
|
1012
|
+
const compareIso = '2026-02-15T00:00:00.000Z';
|
|
1013
|
+
|
|
1014
|
+
const buildStepsXml = (expected: string) =>
|
|
1015
|
+
`<steps id="0" last="1"><step id="1" type="ActionStep">` +
|
|
1016
|
+
`<parameterizedString isformatted="true">Open the app</parameterizedString>` +
|
|
1017
|
+
`<parameterizedString isformatted="true">${expected}</parameterizedString>` +
|
|
1018
|
+
`</step></steps>`;
|
|
1019
|
+
|
|
1020
|
+
(TFSServices.getItemContent as jest.Mock).mockImplementation(
|
|
1021
|
+
async (url: string, _pat: string, method?: string, data?: any) => {
|
|
1022
|
+
if (url.includes('/_apis/wit/queries/q-parsed-steps') && url.includes('api-version=7.1')) {
|
|
1023
|
+
return { name: 'Parsed Steps Q', wiql: 'SELECT [System.Id] FROM WorkItems' };
|
|
1024
|
+
}
|
|
1025
|
+
if (url.includes('/_apis/wit/wiql?') && method === 'post') {
|
|
1026
|
+
return { workItems: [{ id: 701 }] };
|
|
1027
|
+
}
|
|
1028
|
+
if (url.includes('/_apis/wit/workitemsbatch') && method === 'post') {
|
|
1029
|
+
const isBaseline = data.asOf === baselineIso;
|
|
1030
|
+
return {
|
|
1031
|
+
value: [
|
|
1032
|
+
{
|
|
1033
|
+
id: 701,
|
|
1034
|
+
rev: isBaseline ? 1 : 2,
|
|
1035
|
+
fields: {
|
|
1036
|
+
'System.WorkItemType': 'Test Case',
|
|
1037
|
+
'System.Title': 'Login test',
|
|
1038
|
+
'System.State': 'Active',
|
|
1039
|
+
'Microsoft.VSTS.TCM.Steps': buildStepsXml(isBaseline ? 'Login page shown' : 'Dashboard shown'),
|
|
1040
|
+
'System.ChangedDate': isBaseline ? baselineIso : compareIso,
|
|
1041
|
+
},
|
|
1042
|
+
},
|
|
1043
|
+
],
|
|
1044
|
+
};
|
|
1045
|
+
}
|
|
1046
|
+
throw new Error(`unexpected URL: ${url}`);
|
|
1047
|
+
},
|
|
1048
|
+
);
|
|
1049
|
+
|
|
1050
|
+
const result = await provider.CompareHistoricalQueryResults(
|
|
1051
|
+
'q-parsed-steps',
|
|
1052
|
+
project,
|
|
1053
|
+
baselineIso,
|
|
1054
|
+
compareIso,
|
|
1055
|
+
);
|
|
1056
|
+
const row = result.rows.find((r: any) => r.id === 701);
|
|
1057
|
+
expect(row?.compareStatus).toBe('Changed');
|
|
1058
|
+
expect(row?.changedFields).toContain('Steps');
|
|
1059
|
+
const diff = row?.differences.find((d: any) => d.field === 'Steps');
|
|
1060
|
+
expect(diff.baselineSteps).toEqual([
|
|
1061
|
+
expect.objectContaining({ action: 'Open the app', expected: 'Login page shown' }),
|
|
1062
|
+
]);
|
|
1063
|
+
expect(diff.compareToSteps).toEqual([
|
|
1064
|
+
expect.objectContaining({ action: 'Open the app', expected: 'Dashboard shown' }),
|
|
1065
|
+
]);
|
|
1066
|
+
});
|
|
1067
|
+
|
|
1068
|
+
it('CompareHistoricalQueryResults falls back gracefully when Steps XML cannot be parsed', async () => {
|
|
1069
|
+
const baselineIso = '2026-02-20T00:00:00.000Z';
|
|
1070
|
+
const compareIso = '2026-02-25T00:00:00.000Z';
|
|
1071
|
+
|
|
1072
|
+
(TFSServices.getItemContent as jest.Mock).mockImplementation(
|
|
1073
|
+
async (url: string, _pat: string, method?: string, data?: any) => {
|
|
1074
|
+
if (url.includes('/_apis/wit/queries/q-bad-steps') && url.includes('api-version=7.1')) {
|
|
1075
|
+
return { name: 'Bad Steps Q', wiql: 'SELECT [System.Id] FROM WorkItems' };
|
|
1076
|
+
}
|
|
1077
|
+
if (url.includes('/_apis/wit/wiql?') && method === 'post') {
|
|
1078
|
+
return { workItems: [{ id: 801 }] };
|
|
1079
|
+
}
|
|
1080
|
+
if (url.includes('/_apis/wit/workitemsbatch') && method === 'post') {
|
|
1081
|
+
const isBaseline = data.asOf === baselineIso;
|
|
1082
|
+
return {
|
|
1083
|
+
value: [
|
|
1084
|
+
{
|
|
1085
|
+
id: 801,
|
|
1086
|
+
rev: isBaseline ? 1 : 2,
|
|
1087
|
+
fields: {
|
|
1088
|
+
'System.WorkItemType': 'Test Case',
|
|
1089
|
+
'System.Title': 'Malformed steps case',
|
|
1090
|
+
'System.State': 'Active',
|
|
1091
|
+
'Microsoft.VSTS.TCM.Steps': isBaseline ? '<steps><unclosed>' : '<steps><unclosed2>',
|
|
1092
|
+
'System.ChangedDate': isBaseline ? baselineIso : compareIso,
|
|
1093
|
+
},
|
|
1094
|
+
},
|
|
1095
|
+
],
|
|
1096
|
+
};
|
|
1097
|
+
}
|
|
1098
|
+
throw new Error(`unexpected URL: ${url}`);
|
|
1099
|
+
},
|
|
1100
|
+
);
|
|
1101
|
+
|
|
1102
|
+
const result = await provider.CompareHistoricalQueryResults('q-bad-steps', project, baselineIso, compareIso);
|
|
1103
|
+
const row = result.rows.find((r: any) => r.id === 801);
|
|
1104
|
+
expect(row?.compareStatus).toBe('Changed');
|
|
1105
|
+
expect(row?.changedFields).toContain('Steps');
|
|
1106
|
+
const diff = row?.differences.find((d: any) => d.field === 'Steps');
|
|
1107
|
+
expect(diff.baseline).toBe('<steps><unclosed>');
|
|
1108
|
+
expect(diff.compareTo).toBe('<steps><unclosed2>');
|
|
1109
|
+
// TestStepParserHelper.parseTestSteps already swallows its own parse errors and resolves to
|
|
1110
|
+
// an empty list, so the diff carries empty parsed-steps arrays rather than raw XML garbage;
|
|
1111
|
+
// the content-control adapter treats an empty array the same as "no parsed steps".
|
|
1112
|
+
expect(diff.baselineSteps).toEqual([]);
|
|
1113
|
+
expect(diff.compareToSteps).toEqual([]);
|
|
1114
|
+
});
|
|
1115
|
+
|
|
949
1116
|
it('GetHistoricalQueryResults treats a workitemsbatch errorPolicy Omit as skipped without a per-item fallback', async () => {
|
|
950
1117
|
const asOfIso = '2026-01-15T00:00:00.000Z';
|
|
951
1118
|
|
|
@@ -995,73 +1162,158 @@ describe('TicketsDataProvider historical queries', () => {
|
|
|
995
1162
|
).toBe(true);
|
|
996
1163
|
});
|
|
997
1164
|
|
|
998
|
-
it('GetHistoricalQueryResults
|
|
1165
|
+
it('GetHistoricalQueryResults resolves the real Test Phase reference name from the project\'s own field list instead of guessing', async () => {
|
|
999
1166
|
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
1167
|
|
|
1006
1168
|
(TFSServices.getItemContent as jest.Mock).mockImplementation(
|
|
1007
1169
|
async (url: string, _pat: string, method?: string, data?: any) => {
|
|
1008
|
-
if (url.includes('/_apis/wit/queries/q-
|
|
1009
|
-
return { name: '
|
|
1170
|
+
if (url.includes('/_apis/wit/queries/q-resolve-field') && url.includes('api-version=7.1')) {
|
|
1171
|
+
return { name: 'Resolve Field Q', wiql: 'SELECT [System.Id] FROM WorkItems' };
|
|
1010
1172
|
}
|
|
1011
1173
|
if (url.includes('/_apis/wit/wiql?') && method === 'post') {
|
|
1012
|
-
return { workItems:
|
|
1174
|
+
return { workItems: [{ id: 501 }] };
|
|
1175
|
+
}
|
|
1176
|
+
if (url.endsWith('/_apis/wit/fields?api-version=7.1')) {
|
|
1177
|
+
// Neither Elisra.TestPhase nor Custom.TestPhase — a third,
|
|
1178
|
+
// project-specific reference name, discovered only by friendly
|
|
1179
|
+
// name ("Test Phase").
|
|
1180
|
+
return {
|
|
1181
|
+
value: [
|
|
1182
|
+
{ name: 'Title', referenceName: 'System.Title' },
|
|
1183
|
+
{ name: 'Test Phase', referenceName: 'MyCompany.QaPhase' },
|
|
1184
|
+
],
|
|
1185
|
+
};
|
|
1013
1186
|
}
|
|
1014
1187
|
if (url.includes('/_apis/wit/workitemsbatch') && method === 'post') {
|
|
1015
1188
|
if (data?.$expand === 'Relations') {
|
|
1016
|
-
return {
|
|
1017
|
-
value: (Array.isArray(data?.ids) ? data.ids : []).map((id: number) => ({ id, relations: [] })),
|
|
1018
|
-
};
|
|
1189
|
+
return { value: [{ id: 501, relations: [] }] };
|
|
1019
1190
|
}
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1191
|
+
expect(data.fields).toEqual(expect.arrayContaining(['MyCompany.QaPhase']));
|
|
1192
|
+
expect(data.fields).not.toEqual(
|
|
1193
|
+
expect.arrayContaining(['Elisra.TestPhase', 'Custom.TestPhase']),
|
|
1194
|
+
);
|
|
1195
|
+
return {
|
|
1196
|
+
value: [
|
|
1197
|
+
{
|
|
1198
|
+
id: 501,
|
|
1199
|
+
rev: 1,
|
|
1200
|
+
fields: {
|
|
1201
|
+
'System.WorkItemType': 'Test Case',
|
|
1202
|
+
'System.Title': 'Resolved',
|
|
1203
|
+
'System.State': 'Active',
|
|
1204
|
+
'System.ChangedDate': asOfIso,
|
|
1205
|
+
'MyCompany.QaPhase': 'FAT',
|
|
1206
|
+
},
|
|
1025
1207
|
},
|
|
1026
|
-
|
|
1208
|
+
],
|
|
1209
|
+
};
|
|
1210
|
+
}
|
|
1211
|
+
throw new Error(`unexpected URL: ${url}`);
|
|
1212
|
+
},
|
|
1213
|
+
);
|
|
1214
|
+
|
|
1215
|
+
const result = await provider.GetHistoricalQueryResults('q-resolve-field', project, asOfIso);
|
|
1216
|
+
|
|
1217
|
+
expect(result.total).toBe(1);
|
|
1218
|
+
const fieldsListCalls = (TFSServices.getItemContent as jest.Mock).mock.calls.filter((call) =>
|
|
1219
|
+
String(call[0]).endsWith('/_apis/wit/fields?api-version=7.1'),
|
|
1220
|
+
);
|
|
1221
|
+
expect(fieldsListCalls).toHaveLength(1);
|
|
1222
|
+
});
|
|
1223
|
+
|
|
1224
|
+
it('GetHistoricalQueryResults reuses the resolved field across baseline and compareTo without re-querying fields', async () => {
|
|
1225
|
+
const baselineIso = '2026-02-20T00:00:00.000Z';
|
|
1226
|
+
const compareIso = '2026-02-25T00:00:00.000Z';
|
|
1227
|
+
let fieldsListCallCount = 0;
|
|
1228
|
+
|
|
1229
|
+
(TFSServices.getItemContent as jest.Mock).mockImplementation(
|
|
1230
|
+
async (url: string, _pat: string, method?: string, data?: any) => {
|
|
1231
|
+
if (url.includes('/_apis/wit/queries/q-cache-field') && url.includes('api-version=7.1')) {
|
|
1232
|
+
return { name: 'Cache Field Q', wiql: 'SELECT [System.Id] FROM WorkItems' };
|
|
1233
|
+
}
|
|
1234
|
+
if (url.includes('/_apis/wit/wiql?') && method === 'post') {
|
|
1235
|
+
return { workItems: [{ id: 601 }] };
|
|
1236
|
+
}
|
|
1237
|
+
if (url.endsWith('/_apis/wit/fields?api-version=7.1')) {
|
|
1238
|
+
fieldsListCallCount += 1;
|
|
1239
|
+
return { value: [{ name: 'Test Phase', referenceName: 'Custom.TestPhase' }] };
|
|
1240
|
+
}
|
|
1241
|
+
if (url.includes('/_apis/wit/workitemsbatch') && method === 'post') {
|
|
1242
|
+
if (data?.$expand === 'Relations') {
|
|
1243
|
+
return { value: [{ id: 601, relations: [] }] };
|
|
1027
1244
|
}
|
|
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
1245
|
return {
|
|
1034
|
-
value:
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1246
|
+
value: [
|
|
1247
|
+
{
|
|
1248
|
+
id: 601,
|
|
1249
|
+
rev: 1,
|
|
1250
|
+
fields: {
|
|
1251
|
+
'System.WorkItemType': 'Test Case',
|
|
1252
|
+
'System.Title': 'Cached',
|
|
1253
|
+
'System.State': 'Active',
|
|
1254
|
+
'System.ChangedDate': data.asOf,
|
|
1255
|
+
},
|
|
1043
1256
|
},
|
|
1044
|
-
|
|
1257
|
+
],
|
|
1045
1258
|
};
|
|
1046
1259
|
}
|
|
1047
1260
|
throw new Error(`unexpected URL: ${url}`);
|
|
1048
1261
|
},
|
|
1049
1262
|
);
|
|
1050
1263
|
|
|
1051
|
-
|
|
1264
|
+
await provider.CompareHistoricalQueryResults('q-cache-field', project, baselineIso, compareIso);
|
|
1052
1265
|
|
|
1053
|
-
//
|
|
1054
|
-
//
|
|
1055
|
-
expect(
|
|
1056
|
-
|
|
1057
|
-
|
|
1266
|
+
// Baseline and compareTo run concurrently against the same provider
|
|
1267
|
+
// instance — the field list is fetched once, not once per snapshot.
|
|
1268
|
+
expect(fieldsListCallCount).toBe(1);
|
|
1269
|
+
});
|
|
1270
|
+
|
|
1271
|
+
it('GetHistoricalQueryResults omits Test Phase entirely when the project defines neither known name, without failing the batch', async () => {
|
|
1272
|
+
const asOfIso = '2026-03-01T00:00:00.000Z';
|
|
1273
|
+
|
|
1274
|
+
(TFSServices.getItemContent as jest.Mock).mockImplementation(
|
|
1275
|
+
async (url: string, _pat: string, method?: string, data?: any) => {
|
|
1276
|
+
if (url.includes('/_apis/wit/queries/q-no-field') && url.includes('api-version=7.1')) {
|
|
1277
|
+
return { name: 'No Field Q', wiql: 'SELECT [System.Id] FROM WorkItems' };
|
|
1278
|
+
}
|
|
1279
|
+
if (url.includes('/_apis/wit/wiql?') && method === 'post') {
|
|
1280
|
+
return { workItems: [{ id: 701 }] };
|
|
1281
|
+
}
|
|
1282
|
+
if (url.endsWith('/_apis/wit/fields?api-version=7.1')) {
|
|
1283
|
+
return { value: [{ name: 'Title', referenceName: 'System.Title' }] };
|
|
1284
|
+
}
|
|
1285
|
+
if (url.includes('/_apis/wit/workitemsbatch') && method === 'post') {
|
|
1286
|
+
if (data?.$expand === 'Relations') {
|
|
1287
|
+
return { value: [{ id: 701, relations: [] }] };
|
|
1288
|
+
}
|
|
1289
|
+
expect(data.fields).not.toEqual(
|
|
1290
|
+
expect.arrayContaining(['Elisra.TestPhase', 'Custom.TestPhase']),
|
|
1291
|
+
);
|
|
1292
|
+
return {
|
|
1293
|
+
value: [
|
|
1294
|
+
{
|
|
1295
|
+
id: 701,
|
|
1296
|
+
rev: 1,
|
|
1297
|
+
fields: {
|
|
1298
|
+
'System.WorkItemType': 'Bug',
|
|
1299
|
+
'System.Title': 'No test phase field',
|
|
1300
|
+
'System.State': 'Active',
|
|
1301
|
+
'System.ChangedDate': asOfIso,
|
|
1302
|
+
},
|
|
1303
|
+
},
|
|
1304
|
+
],
|
|
1305
|
+
};
|
|
1306
|
+
}
|
|
1307
|
+
throw new Error(`unexpected URL: ${url}`);
|
|
1308
|
+
},
|
|
1058
1309
|
);
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1310
|
+
|
|
1311
|
+
const result = await provider.GetHistoricalQueryResults('q-no-field', project, asOfIso);
|
|
1312
|
+
|
|
1313
|
+
expect(result.total).toBe(1);
|
|
1062
1314
|
expect(
|
|
1063
1315
|
(logger.warn as jest.Mock).mock.calls.some((call) =>
|
|
1064
|
-
String(call[0]).includes("
|
|
1316
|
+
String(call[0]).includes("no Test Phase field defined for project"),
|
|
1065
1317
|
),
|
|
1066
1318
|
).toBe(true);
|
|
1067
1319
|
});
|