@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.
@@ -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 drops only the specific field ADO names in TF51535, keeping Custom.TestPhase when only Elisra.TestPhase is undefined for this project', async () => {
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-unknown-field') && url.includes('api-version=7.1')) {
1009
- return { name: 'Unknown Field Q', wiql: 'SELECT [System.Id] FROM WorkItems' };
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: allIds.map((id) => ({ id })) };
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
- 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'." },
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: (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',
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
- const result = await provider.GetHistoricalQueryResults('q-unknown-field', project, asOfIso);
1264
+ await provider.CompareHistoricalQueryResults('q-cache-field', project, baselineIso, compareIso);
1052
1265
 
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'),
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
- // 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);
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("field 'Elisra.TestPhase' not defined for this project"),
1316
+ String(call[0]).includes("no Test Phase field defined for project"),
1065
1317
  ),
1066
1318
  ).toBe(true);
1067
1319
  });