@elisra-devops/docgen-data-provider 1.137.1 → 1.139.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.
@@ -10,6 +10,7 @@ export default class TicketsDataProvider {
10
10
  token: string;
11
11
  queriesList: Array<any>;
12
12
  private limit;
13
+ private historicalFieldsOverride;
13
14
  constructor(orgUrl: string, token: string);
14
15
  /**
15
16
  * Extracts the Azure DevOps project name from a WIQL URL.
@@ -201,6 +202,25 @@ export default class TicketsDataProvider {
201
202
  }>;
202
203
  private normalizeHistoricalAsOf;
203
204
  private normalizeHistoricalCompareValue;
205
+ /**
206
+ * Stores rich-text fields (Description, Steps) with only whitespace trimmed, preserving markup
207
+ * so the report renderer can clean/render it faithfully. Equality between two snapshots must go
208
+ * through canonicalizeHistoricalRichTextForCompare instead of comparing this value directly.
209
+ */
210
+ private rawHistoricalRichText;
211
+ /**
212
+ * Canonicalizes a rich-text field for equality comparison only. Intentionally identical to the
213
+ * whitespace collapse previously applied at storage time, so Changed/No-changes verdicts and
214
+ * summary counts are unaffected by storing the raw value for rendering.
215
+ */
216
+ private canonicalizeHistoricalRichTextForCompare;
217
+ /**
218
+ * Parses baseline/compareTo Steps XML into per-step Action/Expected arrays for a changed test
219
+ * case, so the report can render readable steps instead of raw Steps XML. Best-effort: a parse
220
+ * failure (malformed XML, shared-step lookup error) must not fail the whole comparison, so it
221
+ * logs and falls back to leaving the caller with only the raw XML strings.
222
+ */
223
+ private parseHistoricalStepsForDiff;
204
224
  private normalizeTestPhaseValue;
205
225
  private isTestCaseType;
206
226
  private toHistoricalRevision;
@@ -214,6 +234,10 @@ export default class TicketsDataProvider {
214
234
  private normalizeHistoricalQueryRoot;
215
235
  private historicalErrorMessage;
216
236
  private isHistoricalMissingWorkItemError;
237
+ private isHistoricalUnknownFieldError;
238
+ private extractHistoricalUnknownFieldName;
239
+ private fetchHistoricalFieldsBatchAttempt;
240
+ private fetchHistoricalFieldsBatch;
217
241
  private fetchHistoricalWorkItemsBatch;
218
242
  private executeHistoricalQueryAtAsOf;
219
243
  private toHistoricalWorkItemSnapshot;
@@ -8,11 +8,12 @@ const tfs_data_3 = require("../models/tfs-data");
8
8
  const tfs_data_4 = require("../models/tfs-data");
9
9
  const tfs_data_5 = require("../models/tfs-data");
10
10
  const tfs_data_6 = require("../models/tfs-data");
11
+ const testStepParserHelper_1 = require("../utils/testStepParserHelper");
11
12
  const logger_1 = require("../utils/logger");
12
13
  const pLimit = require('p-limit');
13
14
  const HISTORICAL_WIT_API_VERSIONS = ['7.1', '5.1', null];
14
15
  const HISTORICAL_BATCH_MAX_IDS = 200;
15
- const HISTORICAL_WORK_ITEM_FIELDS = [
16
+ const HISTORICAL_REQUIRED_WORK_ITEM_FIELDS = [
16
17
  'System.Id',
17
18
  'System.WorkItemType',
18
19
  'System.Title',
@@ -23,8 +24,19 @@ const HISTORICAL_WORK_ITEM_FIELDS = [
23
24
  'System.ChangedDate',
24
25
  'System.Description',
25
26
  'Microsoft.VSTS.TCM.Steps',
26
- 'Elisra.TestPhase',
27
- 'Custom.TestPhase',
27
+ ];
28
+ // Process-template-specific: not every project defines either of these.
29
+ // workitemsbatch's `fields` filter validates every requested reference name
30
+ // against the project's schema and throws TF51535 ("Cannot find field ...")
31
+ // if one is missing — unlike an unfiltered per-item GET, which just omits an
32
+ // unknown field from the response. toHistoricalWorkItemSnapshot already
33
+ // reads TestPhase via a 4-way fallback expecting it might be absent, so it's
34
+ // safe to drop these on a TF51535 and retry without them (see
35
+ // fetchHistoricalWorkItemsBatch).
36
+ const HISTORICAL_OPTIONAL_WORK_ITEM_FIELDS = ['Elisra.TestPhase', 'Custom.TestPhase'];
37
+ const HISTORICAL_WORK_ITEM_FIELDS = [
38
+ ...HISTORICAL_REQUIRED_WORK_ITEM_FIELDS,
39
+ ...HISTORICAL_OPTIONAL_WORK_ITEM_FIELDS,
28
40
  ];
29
41
  /** Default fields fetched per work item in tree/flat query parsing. */
30
42
  const WI_DEFAULT_FIELDS = 'System.Description,System.Title,System.WorkItemType,Microsoft.VSTS.TCM.ReproSteps,Microsoft.VSTS.CMMI.Symptom';
@@ -34,6 +46,9 @@ class TicketsDataProvider {
34
46
  this.token = '';
35
47
  this.queriesList = new Array();
36
48
  this.limit = pLimit(10);
49
+ // Set once a workitemsbatch call reports one of HISTORICAL_OPTIONAL_WORK_ITEM_FIELDS
50
+ // as unknown for this project, so only the first chunk pays the retry cost.
51
+ this.historicalFieldsOverride = null;
37
52
  this.orgUrl = orgUrl;
38
53
  this.token = token;
39
54
  }
@@ -1395,6 +1410,52 @@ class TicketsDataProvider {
1395
1410
  }
1396
1411
  return String(value).trim();
1397
1412
  }
1413
+ /**
1414
+ * Stores rich-text fields (Description, Steps) with only whitespace trimmed, preserving markup
1415
+ * so the report renderer can clean/render it faithfully. Equality between two snapshots must go
1416
+ * through canonicalizeHistoricalRichTextForCompare instead of comparing this value directly.
1417
+ */
1418
+ rawHistoricalRichText(value) {
1419
+ if (value == null) {
1420
+ return '';
1421
+ }
1422
+ if (typeof value === 'string') {
1423
+ return value.trim();
1424
+ }
1425
+ return this.normalizeHistoricalCompareValue(value);
1426
+ }
1427
+ /**
1428
+ * Canonicalizes a rich-text field for equality comparison only. Intentionally identical to the
1429
+ * whitespace collapse previously applied at storage time, so Changed/No-changes verdicts and
1430
+ * summary counts are unaffected by storing the raw value for rendering.
1431
+ */
1432
+ canonicalizeHistoricalRichTextForCompare(value) {
1433
+ return this.normalizeHistoricalCompareValue(value);
1434
+ }
1435
+ /**
1436
+ * Parses baseline/compareTo Steps XML into per-step Action/Expected arrays for a changed test
1437
+ * case, so the report can render readable steps instead of raw Steps XML. Best-effort: a parse
1438
+ * failure (malformed XML, shared-step lookup error) must not fail the whole comparison, so it
1439
+ * logs and falls back to leaving the caller with only the raw XML strings.
1440
+ */
1441
+ async parseHistoricalStepsForDiff(testStepParserHelper, baselineStepsXml, compareToStepsXml) {
1442
+ try {
1443
+ const emptyRevisionMap = new Map();
1444
+ const [baselineSteps, compareToSteps] = await Promise.all([
1445
+ baselineStepsXml
1446
+ ? testStepParserHelper.parseTestSteps(baselineStepsXml, emptyRevisionMap)
1447
+ : Promise.resolve([]),
1448
+ compareToStepsXml
1449
+ ? testStepParserHelper.parseTestSteps(compareToStepsXml, emptyRevisionMap)
1450
+ : Promise.resolve([]),
1451
+ ]);
1452
+ return { baselineSteps, compareToSteps };
1453
+ }
1454
+ catch (err) {
1455
+ logger_1.default.warn(`Failed to parse historical Steps XML for diff rendering: ${(err === null || err === void 0 ? void 0 : err.message) || err}`);
1456
+ return null;
1457
+ }
1458
+ }
1398
1459
  normalizeTestPhaseValue(value) {
1399
1460
  const rendered = this.normalizeHistoricalCompareValue(value);
1400
1461
  if (!rendered) {
@@ -1571,6 +1632,61 @@ class TicketsDataProvider {
1571
1632
  }
1572
1633
  return status === 404 || status === 410;
1573
1634
  }
1635
+ isHistoricalUnknownFieldError(error) {
1636
+ const message = this.historicalErrorMessage(error);
1637
+ return /TF51535|Cannot find field/i.test(message);
1638
+ }
1639
+ // ADO's TF51535 names the specific field it couldn't find, e.g.
1640
+ // "TF51535: Cannot find field 'Elisra.TestPhase'." — different networks
1641
+ // define this optional field under different reference names (Elisra.* vs
1642
+ // Custom.*), so drop only the one ADO actually rejected rather than every
1643
+ // optional field, or a network that defines Custom.TestPhase but not
1644
+ // Elisra.TestPhase would lose Test Phase comparison entirely for no reason.
1645
+ extractHistoricalUnknownFieldName(error) {
1646
+ const message = this.historicalErrorMessage(error);
1647
+ const quoted = message.match(/Cannot find field\s*'([^']+)'/i);
1648
+ if (quoted === null || quoted === void 0 ? void 0 : quoted[1])
1649
+ return quoted[1];
1650
+ return HISTORICAL_OPTIONAL_WORK_ITEM_FIELDS.find((field) => message.includes(field)) || null;
1651
+ }
1652
+ fetchHistoricalFieldsBatchAttempt(workItemsBatchUrl, idChunk, asOf, fieldsForRequest, attemptsRemaining) {
1653
+ return this.limit(() => tfs_1.TFSServices.getItemContent(workItemsBatchUrl, this.token, 'post', {
1654
+ ids: idChunk,
1655
+ asOf,
1656
+ fields: fieldsForRequest,
1657
+ errorPolicy: 'Omit',
1658
+ })).catch((error) => {
1659
+ if (attemptsRemaining <= 0 || !this.isHistoricalUnknownFieldError(error)) {
1660
+ throw error;
1661
+ }
1662
+ const unknownField = this.extractHistoricalUnknownFieldName(error);
1663
+ // Only strip fields this call actually recognizes as droppable
1664
+ // (named explicitly, or one of the known optional fields as a
1665
+ // last-resort fallback if the message doesn't parse) — never strip a
1666
+ // required field, so an unrelated schema issue still surfaces as an
1667
+ // error instead of silently losing data.
1668
+ const reducedFields = unknownField
1669
+ ? fieldsForRequest.filter((field) => field !== unknownField)
1670
+ : fieldsForRequest.filter((field) => !HISTORICAL_OPTIONAL_WORK_ITEM_FIELDS.includes(field));
1671
+ if (reducedFields.length === fieldsForRequest.length) {
1672
+ throw error;
1673
+ }
1674
+ // Concurrent chunks can each discover a different bad field before any
1675
+ // of them updates the shared override — intersect rather than
1676
+ // overwrite, so one chunk's retry never re-requests a field another
1677
+ // chunk already proved invalid for this project.
1678
+ this.historicalFieldsOverride = this.historicalFieldsOverride
1679
+ ? this.historicalFieldsOverride.filter((field) => reducedFields.includes(field))
1680
+ : reducedFields;
1681
+ logger_1.default.warn(`[historical-workitems] field${unknownField ? ` '${unknownField}'` : '(s)'} not defined ` +
1682
+ `for this project; retrying workitemsbatch without it: ${this.historicalErrorMessage(error)}`);
1683
+ return this.fetchHistoricalFieldsBatchAttempt(workItemsBatchUrl, idChunk, asOf, reducedFields, attemptsRemaining - 1);
1684
+ });
1685
+ }
1686
+ fetchHistoricalFieldsBatch(workItemsBatchUrl, idChunk, asOf) {
1687
+ const fieldsForRequest = this.historicalFieldsOverride || HISTORICAL_WORK_ITEM_FIELDS;
1688
+ return this.fetchHistoricalFieldsBatchAttempt(workItemsBatchUrl, idChunk, asOf, fieldsForRequest, HISTORICAL_OPTIONAL_WORK_ITEM_FIELDS.length);
1689
+ }
1574
1690
  async fetchHistoricalWorkItemsBatch(project, ids, asOf, apiVersion) {
1575
1691
  const workItemsBatchUrl = this.appendApiVersion(`${this.orgUrl}${project}/_apis/wit/workitemsbatch`, apiVersion);
1576
1692
  const idChunks = this.chunkHistoricalWorkItemIds(ids);
@@ -1588,12 +1704,7 @@ class TicketsDataProvider {
1588
1704
  // two units against the shared concurrency cap, not one — otherwise
1589
1705
  // the real ceiling on simultaneous ADO calls would silently double.
1590
1706
  Promise.all([
1591
- this.limit(() => tfs_1.TFSServices.getItemContent(workItemsBatchUrl, this.token, 'post', {
1592
- ids: idChunk,
1593
- asOf,
1594
- fields: HISTORICAL_WORK_ITEM_FIELDS,
1595
- errorPolicy: 'Omit',
1596
- })),
1707
+ this.fetchHistoricalFieldsBatch(workItemsBatchUrl, idChunk, asOf),
1597
1708
  this.limit(() => tfs_1.TFSServices.getItemContent(workItemsBatchUrl, this.token, 'post', {
1598
1709
  ids: idChunk,
1599
1710
  asOf,
@@ -1716,8 +1827,8 @@ class TicketsDataProvider {
1716
1827
  iterationPath: this.normalizeHistoricalCompareValue(fields['System.IterationPath']),
1717
1828
  versionId: this.toHistoricalRevision((_d = workItem === null || workItem === void 0 ? void 0 : workItem.rev) !== null && _d !== void 0 ? _d : fields['System.Rev']),
1718
1829
  versionTimestamp: this.normalizeHistoricalCompareValue(fields['System.ChangedDate']),
1719
- description: this.normalizeHistoricalCompareValue(fields['System.Description']),
1720
- steps: this.normalizeHistoricalCompareValue(fields['Microsoft.VSTS.TCM.Steps']),
1830
+ description: this.rawHistoricalRichText(fields['System.Description']),
1831
+ steps: this.rawHistoricalRichText(fields['Microsoft.VSTS.TCM.Steps']),
1721
1832
  testPhase: this.normalizeTestPhaseValue(testPhaseRaw),
1722
1833
  relatedLinkCount,
1723
1834
  workItemUrl: `${this.orgUrl}${project}/_workitems/edit/${id}`,
@@ -1853,7 +1964,8 @@ class TicketsDataProvider {
1853
1964
  ...Array.from(compareTo.snapshotMap.keys()),
1854
1965
  ]);
1855
1966
  const sortedIds = Array.from(allIds.values()).sort((a, b) => a - b);
1856
- const rows = sortedIds.map((id) => {
1967
+ const testStepParserHelper = new testStepParserHelper_1.default(this.orgUrl, this.token);
1968
+ const rows = await Promise.all(sortedIds.map(async (id) => {
1857
1969
  const baselineRow = baseline.snapshotMap.get(id) || null;
1858
1970
  const compareToRow = compareTo.snapshotMap.get(id) || null;
1859
1971
  const workItemType = (compareToRow === null || compareToRow === void 0 ? void 0 : compareToRow.workItemType) || (baselineRow === null || baselineRow === void 0 ? void 0 : baselineRow.workItemType) || '';
@@ -1887,7 +1999,12 @@ class TicketsDataProvider {
1887
1999
  const safeBaseline = baselineRow;
1888
2000
  const safeCompareTo = compareToRow;
1889
2001
  const changedFields = [];
1890
- if (safeBaseline.description !== safeCompareTo.description) {
2002
+ const descriptionChanged = this.canonicalizeHistoricalRichTextForCompare(safeBaseline.description) !==
2003
+ this.canonicalizeHistoricalRichTextForCompare(safeCompareTo.description);
2004
+ const stepsChanged = isTestCase &&
2005
+ this.canonicalizeHistoricalRichTextForCompare(safeBaseline.steps) !==
2006
+ this.canonicalizeHistoricalRichTextForCompare(safeCompareTo.steps);
2007
+ if (descriptionChanged) {
1891
2008
  changedFields.push('Description');
1892
2009
  }
1893
2010
  if (safeBaseline.title !== safeCompareTo.title) {
@@ -1896,7 +2013,7 @@ class TicketsDataProvider {
1896
2013
  if (safeBaseline.state !== safeCompareTo.state) {
1897
2014
  changedFields.push('State');
1898
2015
  }
1899
- if (isTestCase && safeBaseline.steps !== safeCompareTo.steps) {
2016
+ if (stepsChanged) {
1900
2017
  changedFields.push('Steps');
1901
2018
  }
1902
2019
  if (safeBaseline.testPhase !== safeCompareTo.testPhase) {
@@ -1905,7 +2022,7 @@ class TicketsDataProvider {
1905
2022
  if (isTestCase && safeBaseline.relatedLinkCount !== safeCompareTo.relatedLinkCount) {
1906
2023
  changedFields.push('Related Link Count');
1907
2024
  }
1908
- const differences = changedFields.map((field) => {
2025
+ const differences = await Promise.all(changedFields.map(async (field) => {
1909
2026
  switch (field) {
1910
2027
  case 'Description':
1911
2028
  return { field, baseline: safeBaseline.description, compareTo: safeCompareTo.description };
@@ -1913,8 +2030,10 @@ class TicketsDataProvider {
1913
2030
  return { field, baseline: safeBaseline.title, compareTo: safeCompareTo.title };
1914
2031
  case 'State':
1915
2032
  return { field, baseline: safeBaseline.state, compareTo: safeCompareTo.state };
1916
- case 'Steps':
1917
- return { field, baseline: safeBaseline.steps, compareTo: safeCompareTo.steps };
2033
+ case 'Steps': {
2034
+ const parsed = await this.parseHistoricalStepsForDiff(testStepParserHelper, safeBaseline.steps, safeCompareTo.steps);
2035
+ return Object.assign({ field, baseline: safeBaseline.steps, compareTo: safeCompareTo.steps }, (parsed || {}));
2036
+ }
1918
2037
  case 'Test Phase':
1919
2038
  return { field, baseline: safeBaseline.testPhase, compareTo: safeCompareTo.testPhase };
1920
2039
  case 'Related Link Count':
@@ -1926,7 +2045,7 @@ class TicketsDataProvider {
1926
2045
  default:
1927
2046
  return { field, baseline: '', compareTo: '' };
1928
2047
  }
1929
- });
2048
+ }));
1930
2049
  return {
1931
2050
  id,
1932
2051
  workItemType,
@@ -1938,7 +2057,7 @@ class TicketsDataProvider {
1938
2057
  differences,
1939
2058
  workItemUrl: safeCompareTo.workItemUrl || safeBaseline.workItemUrl,
1940
2059
  };
1941
- });
2060
+ }));
1942
2061
  const summary = rows.reduce((acc, row) => {
1943
2062
  if (row.compareStatus === 'Added')
1944
2063
  acc.addedCount += 1;