@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.
@@ -10,7 +10,7 @@ export default class TicketsDataProvider {
10
10
  token: string;
11
11
  queriesList: Array<any>;
12
12
  private limit;
13
- private historicalFieldsOverride;
13
+ private historicalTestPhaseFieldByProject;
14
14
  constructor(orgUrl: string, token: string);
15
15
  /**
16
16
  * Extracts the Azure DevOps project name from a WIQL URL.
@@ -202,6 +202,25 @@ export default class TicketsDataProvider {
202
202
  }>;
203
203
  private normalizeHistoricalAsOf;
204
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;
205
224
  private normalizeTestPhaseValue;
206
225
  private isTestCaseType;
207
226
  private toHistoricalRevision;
@@ -216,8 +235,8 @@ export default class TicketsDataProvider {
216
235
  private historicalErrorMessage;
217
236
  private isHistoricalMissingWorkItemError;
218
237
  private isHistoricalUnknownFieldError;
219
- private extractHistoricalUnknownFieldName;
220
- private fetchHistoricalFieldsBatchAttempt;
238
+ private resolveHistoricalTestPhaseField;
239
+ private discoverHistoricalTestPhaseField;
221
240
  private fetchHistoricalFieldsBatch;
222
241
  private fetchHistoricalWorkItemsBatch;
223
242
  private executeHistoricalQueryAtAsOf;
@@ -8,6 +8,7 @@ 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];
@@ -45,9 +46,11 @@ class TicketsDataProvider {
45
46
  this.token = '';
46
47
  this.queriesList = new Array();
47
48
  this.limit = pLimit(10);
48
- // Set once a workitemsbatch call reports one of HISTORICAL_OPTIONAL_WORK_ITEM_FIELDS
49
- // as unknown for this project, so only the first chunk pays the retry cost.
50
- this.historicalFieldsOverride = null;
49
+ // Resolved once per project via resolveHistoricalTestPhaseField, so a
50
+ // compare's baseline and compareTo snapshots (and repeated queries against
51
+ // the same provider instance) only pay the discovery call once. Value is
52
+ // the real reference name, or null if the project defines no such field.
53
+ this.historicalTestPhaseFieldByProject = new Map();
51
54
  this.orgUrl = orgUrl;
52
55
  this.token = token;
53
56
  }
@@ -1409,6 +1412,52 @@ class TicketsDataProvider {
1409
1412
  }
1410
1413
  return String(value).trim();
1411
1414
  }
1415
+ /**
1416
+ * Stores rich-text fields (Description, Steps) with only whitespace trimmed, preserving markup
1417
+ * so the report renderer can clean/render it faithfully. Equality between two snapshots must go
1418
+ * through canonicalizeHistoricalRichTextForCompare instead of comparing this value directly.
1419
+ */
1420
+ rawHistoricalRichText(value) {
1421
+ if (value == null) {
1422
+ return '';
1423
+ }
1424
+ if (typeof value === 'string') {
1425
+ return value.trim();
1426
+ }
1427
+ return this.normalizeHistoricalCompareValue(value);
1428
+ }
1429
+ /**
1430
+ * Canonicalizes a rich-text field for equality comparison only. Intentionally identical to the
1431
+ * whitespace collapse previously applied at storage time, so Changed/No-changes verdicts and
1432
+ * summary counts are unaffected by storing the raw value for rendering.
1433
+ */
1434
+ canonicalizeHistoricalRichTextForCompare(value) {
1435
+ return this.normalizeHistoricalCompareValue(value);
1436
+ }
1437
+ /**
1438
+ * Parses baseline/compareTo Steps XML into per-step Action/Expected arrays for a changed test
1439
+ * case, so the report can render readable steps instead of raw Steps XML. Best-effort: a parse
1440
+ * failure (malformed XML, shared-step lookup error) must not fail the whole comparison, so it
1441
+ * logs and falls back to leaving the caller with only the raw XML strings.
1442
+ */
1443
+ async parseHistoricalStepsForDiff(testStepParserHelper, baselineStepsXml, compareToStepsXml) {
1444
+ try {
1445
+ const emptyRevisionMap = new Map();
1446
+ const [baselineSteps, compareToSteps] = await Promise.all([
1447
+ baselineStepsXml
1448
+ ? testStepParserHelper.parseTestSteps(baselineStepsXml, emptyRevisionMap)
1449
+ : Promise.resolve([]),
1450
+ compareToStepsXml
1451
+ ? testStepParserHelper.parseTestSteps(compareToStepsXml, emptyRevisionMap)
1452
+ : Promise.resolve([]),
1453
+ ]);
1454
+ return { baselineSteps, compareToSteps };
1455
+ }
1456
+ catch (err) {
1457
+ logger_1.default.warn(`Failed to parse historical Steps XML for diff rendering: ${(err === null || err === void 0 ? void 0 : err.message) || err}`);
1458
+ return null;
1459
+ }
1460
+ }
1412
1461
  normalizeTestPhaseValue(value) {
1413
1462
  const rendered = this.normalizeHistoricalCompareValue(value);
1414
1463
  if (!rendered) {
@@ -1589,59 +1638,80 @@ class TicketsDataProvider {
1589
1638
  const message = this.historicalErrorMessage(error);
1590
1639
  return /TF51535|Cannot find field/i.test(message);
1591
1640
  }
1592
- // ADO's TF51535 names the specific field it couldn't find, e.g.
1593
- // "TF51535: Cannot find field 'Elisra.TestPhase'." — different networks
1594
- // define this optional field under different reference names (Elisra.* vs
1595
- // Custom.*), so drop only the one ADO actually rejected rather than every
1596
- // optional field, or a network that defines Custom.TestPhase but not
1597
- // Elisra.TestPhase would lose Test Phase comparison entirely for no reason.
1598
- extractHistoricalUnknownFieldName(error) {
1599
- const message = this.historicalErrorMessage(error);
1600
- const quoted = message.match(/Cannot find field\s*'([^']+)'/i);
1601
- if (quoted === null || quoted === void 0 ? void 0 : quoted[1])
1602
- return quoted[1];
1603
- return HISTORICAL_OPTIONAL_WORK_ITEM_FIELDS.find((field) => message.includes(field)) || null;
1641
+ // Different networks define the "Test Phase" custom field under different
1642
+ // reference names (Elisra.TestPhase vs Custom.TestPhase, or something else
1643
+ // entirely on a third network) — hardcoding candidate names and guessing is
1644
+ // inherently fragile. Resolve the project's real field list once (Fields -
1645
+ // List: GET {org}/{project}/_apis/wit/fields, supported since old TFS API
1646
+ // versions) and use whichever reference name actually matches, by
1647
+ // reference name or by friendly name. Resolves to null if the project
1648
+ // defines no such field at all.
1649
+ //
1650
+ // Caches the in-flight promise, not just the resolved value: a compare's
1651
+ // baseline and compareTo snapshots run concurrently against the same
1652
+ // provider instance, and both would otherwise read the cache before either
1653
+ // had a value to store, issuing the discovery request twice.
1654
+ resolveHistoricalTestPhaseField(project, apiVersion) {
1655
+ const cached = this.historicalTestPhaseFieldByProject.get(project);
1656
+ if (cached)
1657
+ return cached;
1658
+ const promise = this.discoverHistoricalTestPhaseField(project, apiVersion);
1659
+ this.historicalTestPhaseFieldByProject.set(project, promise);
1660
+ return promise;
1661
+ }
1662
+ async discoverHistoricalTestPhaseField(project, apiVersion) {
1663
+ const fieldsUrl = this.appendApiVersion(`${this.orgUrl}${project}/_apis/wit/fields`, apiVersion);
1664
+ try {
1665
+ const response = await tfs_1.TFSServices.getItemContent(fieldsUrl, this.token);
1666
+ const fields = Array.isArray(response === null || response === void 0 ? void 0 : response.value) ? response.value : [];
1667
+ const match = fields.find((field) => HISTORICAL_OPTIONAL_WORK_ITEM_FIELDS.some((candidate) => String((field === null || field === void 0 ? void 0 : field.referenceName) || '').toLowerCase() === candidate.toLowerCase())) || fields.find((field) => /test\s*phase/i.test(String((field === null || field === void 0 ? void 0 : field.name) || '')));
1668
+ const resolved = (match === null || match === void 0 ? void 0 : match.referenceName) ? String(match.referenceName) : null;
1669
+ if (!resolved) {
1670
+ logger_1.default.warn(`[historical-workitems] no Test Phase field defined for project '${project}'`);
1671
+ }
1672
+ return resolved;
1673
+ }
1674
+ catch (error) {
1675
+ logger_1.default.warn(`[historical-workitems] could not list fields for project '${project}' to resolve the ` +
1676
+ `Test Phase field name, omitting it: ${this.historicalErrorMessage(error)}`);
1677
+ return null;
1678
+ }
1604
1679
  }
1605
- fetchHistoricalFieldsBatchAttempt(workItemsBatchUrl, idChunk, asOf, fieldsForRequest, attemptsRemaining) {
1680
+ fetchHistoricalFieldsBatch(workItemsBatchUrl, idChunk, asOf, project, fieldsForRequest) {
1606
1681
  return this.limit(() => tfs_1.TFSServices.getItemContent(workItemsBatchUrl, this.token, 'post', {
1607
1682
  ids: idChunk,
1608
1683
  asOf,
1609
1684
  fields: fieldsForRequest,
1610
1685
  errorPolicy: 'Omit',
1611
1686
  })).catch((error) => {
1612
- if (attemptsRemaining <= 0 || !this.isHistoricalUnknownFieldError(error)) {
1613
- throw error;
1614
- }
1615
- const unknownField = this.extractHistoricalUnknownFieldName(error);
1616
- // Only strip fields this call actually recognizes as droppable
1617
- // (named explicitly, or one of the known optional fields as a
1618
- // last-resort fallback if the message doesn't parse) — never strip a
1619
- // required field, so an unrelated schema issue still surfaces as an
1620
- // error instead of silently losing data.
1621
- const reducedFields = unknownField
1622
- ? fieldsForRequest.filter((field) => field !== unknownField)
1623
- : fieldsForRequest.filter((field) => !HISTORICAL_OPTIONAL_WORK_ITEM_FIELDS.includes(field));
1624
- if (reducedFields.length === fieldsForRequest.length) {
1687
+ // Defensive net: the resolved field name came from the project's own
1688
+ // field list, so this shouldn't fire, but if it somehow still does,
1689
+ // fall back to the required fields and poison the cache so later
1690
+ // chunks/queries for this project stop requesting it too.
1691
+ const hasOptionalField = HISTORICAL_OPTIONAL_WORK_ITEM_FIELDS.some((f) => fieldsForRequest.includes(f))
1692
+ || fieldsForRequest.some((f) => !HISTORICAL_REQUIRED_WORK_ITEM_FIELDS.includes(f));
1693
+ if (!hasOptionalField || !this.isHistoricalUnknownFieldError(error)) {
1625
1694
  throw error;
1626
1695
  }
1627
- // Concurrent chunks can each discover a different bad field before any
1628
- // of them updates the shared override — intersect rather than
1629
- // overwrite, so one chunk's retry never re-requests a field another
1630
- // chunk already proved invalid for this project.
1631
- this.historicalFieldsOverride = this.historicalFieldsOverride
1632
- ? this.historicalFieldsOverride.filter((field) => reducedFields.includes(field))
1633
- : reducedFields;
1634
- logger_1.default.warn(`[historical-workitems] field${unknownField ? ` '${unknownField}'` : '(s)'} not defined ` +
1635
- `for this project; retrying workitemsbatch without it: ${this.historicalErrorMessage(error)}`);
1636
- return this.fetchHistoricalFieldsBatchAttempt(workItemsBatchUrl, idChunk, asOf, reducedFields, attemptsRemaining - 1);
1696
+ logger_1.default.warn(`[historical-workitems] resolved Test Phase field was rejected by workitemsbatch for ` +
1697
+ `project '${project}'; retrying without it: ${this.historicalErrorMessage(error)}`);
1698
+ this.historicalTestPhaseFieldByProject.set(project, Promise.resolve(null));
1699
+ return this.limit(() => tfs_1.TFSServices.getItemContent(workItemsBatchUrl, this.token, 'post', {
1700
+ ids: idChunk,
1701
+ asOf,
1702
+ fields: HISTORICAL_REQUIRED_WORK_ITEM_FIELDS,
1703
+ errorPolicy: 'Omit',
1704
+ }));
1637
1705
  });
1638
1706
  }
1639
- fetchHistoricalFieldsBatch(workItemsBatchUrl, idChunk, asOf) {
1640
- const fieldsForRequest = this.historicalFieldsOverride || HISTORICAL_WORK_ITEM_FIELDS;
1641
- return this.fetchHistoricalFieldsBatchAttempt(workItemsBatchUrl, idChunk, asOf, fieldsForRequest, HISTORICAL_OPTIONAL_WORK_ITEM_FIELDS.length);
1642
- }
1643
1707
  async fetchHistoricalWorkItemsBatch(project, ids, asOf, apiVersion) {
1644
1708
  const workItemsBatchUrl = this.appendApiVersion(`${this.orgUrl}${project}/_apis/wit/workitemsbatch`, apiVersion);
1709
+ // Resolved once per project, before any chunk starts, so every chunk
1710
+ // shares the same field list — no per-request guessing or retry race.
1711
+ const testPhaseField = await this.resolveHistoricalTestPhaseField(project, apiVersion);
1712
+ const fieldsForRequest = testPhaseField
1713
+ ? [...HISTORICAL_REQUIRED_WORK_ITEM_FIELDS, testPhaseField]
1714
+ : HISTORICAL_REQUIRED_WORK_ITEM_FIELDS;
1645
1715
  const idChunks = this.chunkHistoricalWorkItemIds(ids);
1646
1716
  try {
1647
1717
  const chunkResults = await Promise.all(idChunks.map((idChunk) =>
@@ -1657,7 +1727,7 @@ class TicketsDataProvider {
1657
1727
  // two units against the shared concurrency cap, not one — otherwise
1658
1728
  // the real ceiling on simultaneous ADO calls would silently double.
1659
1729
  Promise.all([
1660
- this.fetchHistoricalFieldsBatch(workItemsBatchUrl, idChunk, asOf),
1730
+ this.fetchHistoricalFieldsBatch(workItemsBatchUrl, idChunk, asOf, project, fieldsForRequest),
1661
1731
  this.limit(() => tfs_1.TFSServices.getItemContent(workItemsBatchUrl, this.token, 'post', {
1662
1732
  ids: idChunk,
1663
1733
  asOf,
@@ -1780,8 +1850,8 @@ class TicketsDataProvider {
1780
1850
  iterationPath: this.normalizeHistoricalCompareValue(fields['System.IterationPath']),
1781
1851
  versionId: this.toHistoricalRevision((_d = workItem === null || workItem === void 0 ? void 0 : workItem.rev) !== null && _d !== void 0 ? _d : fields['System.Rev']),
1782
1852
  versionTimestamp: this.normalizeHistoricalCompareValue(fields['System.ChangedDate']),
1783
- description: this.normalizeHistoricalCompareValue(fields['System.Description']),
1784
- steps: this.normalizeHistoricalCompareValue(fields['Microsoft.VSTS.TCM.Steps']),
1853
+ description: this.rawHistoricalRichText(fields['System.Description']),
1854
+ steps: this.rawHistoricalRichText(fields['Microsoft.VSTS.TCM.Steps']),
1785
1855
  testPhase: this.normalizeTestPhaseValue(testPhaseRaw),
1786
1856
  relatedLinkCount,
1787
1857
  workItemUrl: `${this.orgUrl}${project}/_workitems/edit/${id}`,
@@ -1917,7 +1987,8 @@ class TicketsDataProvider {
1917
1987
  ...Array.from(compareTo.snapshotMap.keys()),
1918
1988
  ]);
1919
1989
  const sortedIds = Array.from(allIds.values()).sort((a, b) => a - b);
1920
- const rows = sortedIds.map((id) => {
1990
+ const testStepParserHelper = new testStepParserHelper_1.default(this.orgUrl, this.token);
1991
+ const rows = await Promise.all(sortedIds.map(async (id) => {
1921
1992
  const baselineRow = baseline.snapshotMap.get(id) || null;
1922
1993
  const compareToRow = compareTo.snapshotMap.get(id) || null;
1923
1994
  const workItemType = (compareToRow === null || compareToRow === void 0 ? void 0 : compareToRow.workItemType) || (baselineRow === null || baselineRow === void 0 ? void 0 : baselineRow.workItemType) || '';
@@ -1951,7 +2022,12 @@ class TicketsDataProvider {
1951
2022
  const safeBaseline = baselineRow;
1952
2023
  const safeCompareTo = compareToRow;
1953
2024
  const changedFields = [];
1954
- if (safeBaseline.description !== safeCompareTo.description) {
2025
+ const descriptionChanged = this.canonicalizeHistoricalRichTextForCompare(safeBaseline.description) !==
2026
+ this.canonicalizeHistoricalRichTextForCompare(safeCompareTo.description);
2027
+ const stepsChanged = isTestCase &&
2028
+ this.canonicalizeHistoricalRichTextForCompare(safeBaseline.steps) !==
2029
+ this.canonicalizeHistoricalRichTextForCompare(safeCompareTo.steps);
2030
+ if (descriptionChanged) {
1955
2031
  changedFields.push('Description');
1956
2032
  }
1957
2033
  if (safeBaseline.title !== safeCompareTo.title) {
@@ -1960,7 +2036,7 @@ class TicketsDataProvider {
1960
2036
  if (safeBaseline.state !== safeCompareTo.state) {
1961
2037
  changedFields.push('State');
1962
2038
  }
1963
- if (isTestCase && safeBaseline.steps !== safeCompareTo.steps) {
2039
+ if (stepsChanged) {
1964
2040
  changedFields.push('Steps');
1965
2041
  }
1966
2042
  if (safeBaseline.testPhase !== safeCompareTo.testPhase) {
@@ -1969,7 +2045,7 @@ class TicketsDataProvider {
1969
2045
  if (isTestCase && safeBaseline.relatedLinkCount !== safeCompareTo.relatedLinkCount) {
1970
2046
  changedFields.push('Related Link Count');
1971
2047
  }
1972
- const differences = changedFields.map((field) => {
2048
+ const differences = await Promise.all(changedFields.map(async (field) => {
1973
2049
  switch (field) {
1974
2050
  case 'Description':
1975
2051
  return { field, baseline: safeBaseline.description, compareTo: safeCompareTo.description };
@@ -1977,8 +2053,10 @@ class TicketsDataProvider {
1977
2053
  return { field, baseline: safeBaseline.title, compareTo: safeCompareTo.title };
1978
2054
  case 'State':
1979
2055
  return { field, baseline: safeBaseline.state, compareTo: safeCompareTo.state };
1980
- case 'Steps':
1981
- return { field, baseline: safeBaseline.steps, compareTo: safeCompareTo.steps };
2056
+ case 'Steps': {
2057
+ const parsed = await this.parseHistoricalStepsForDiff(testStepParserHelper, safeBaseline.steps, safeCompareTo.steps);
2058
+ return Object.assign({ field, baseline: safeBaseline.steps, compareTo: safeCompareTo.steps }, (parsed || {}));
2059
+ }
1982
2060
  case 'Test Phase':
1983
2061
  return { field, baseline: safeBaseline.testPhase, compareTo: safeCompareTo.testPhase };
1984
2062
  case 'Related Link Count':
@@ -1990,7 +2068,7 @@ class TicketsDataProvider {
1990
2068
  default:
1991
2069
  return { field, baseline: '', compareTo: '' };
1992
2070
  }
1993
- });
2071
+ }));
1994
2072
  return {
1995
2073
  id,
1996
2074
  workItemType,
@@ -2002,7 +2080,7 @@ class TicketsDataProvider {
2002
2080
  differences,
2003
2081
  workItemUrl: safeCompareTo.workItemUrl || safeBaseline.workItemUrl,
2004
2082
  };
2005
- });
2083
+ }));
2006
2084
  const summary = rows.reduce((acc, row) => {
2007
2085
  if (row.compareStatus === 'Added')
2008
2086
  acc.addedCount += 1;