@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.
@@ -6,6 +6,8 @@ import { QueryType } from '../models/tfs-data';
6
6
  import { QueryAllTypes } from '../models/tfs-data';
7
7
  import { Column } from '../models/tfs-data';
8
8
  import { value } from '../models/tfs-data';
9
+ import { TestSteps } from '../models/tfs-data';
10
+ import TestStepParserHelper from '../utils/testStepParserHelper';
9
11
 
10
12
  import logger from '../utils/logger';
11
13
  const pLimit = require('p-limit');
@@ -96,9 +98,11 @@ export default class TicketsDataProvider {
96
98
  token: string = '';
97
99
  queriesList: Array<any> = new Array<any>();
98
100
  private limit = pLimit(10);
99
- // Set once a workitemsbatch call reports one of HISTORICAL_OPTIONAL_WORK_ITEM_FIELDS
100
- // as unknown for this project, so only the first chunk pays the retry cost.
101
- private historicalFieldsOverride: string[] | null = null;
101
+ // Resolved once per project via resolveHistoricalTestPhaseField, so a
102
+ // compare's baseline and compareTo snapshots (and repeated queries against
103
+ // the same provider instance) only pay the discovery call once. Value is
104
+ // the real reference name, or null if the project defines no such field.
105
+ private historicalTestPhaseFieldByProject = new Map<string, Promise<string | null>>();
102
106
 
103
107
  constructor(orgUrl: string, token: string) {
104
108
  this.orgUrl = orgUrl;
@@ -1786,6 +1790,58 @@ export default class TicketsDataProvider {
1786
1790
  return String(value).trim();
1787
1791
  }
1788
1792
 
1793
+ /**
1794
+ * Stores rich-text fields (Description, Steps) with only whitespace trimmed, preserving markup
1795
+ * so the report renderer can clean/render it faithfully. Equality between two snapshots must go
1796
+ * through canonicalizeHistoricalRichTextForCompare instead of comparing this value directly.
1797
+ */
1798
+ private rawHistoricalRichText(value: unknown): string {
1799
+ if (value == null) {
1800
+ return '';
1801
+ }
1802
+ if (typeof value === 'string') {
1803
+ return value.trim();
1804
+ }
1805
+ return this.normalizeHistoricalCompareValue(value);
1806
+ }
1807
+
1808
+ /**
1809
+ * Canonicalizes a rich-text field for equality comparison only. Intentionally identical to the
1810
+ * whitespace collapse previously applied at storage time, so Changed/No-changes verdicts and
1811
+ * summary counts are unaffected by storing the raw value for rendering.
1812
+ */
1813
+ private canonicalizeHistoricalRichTextForCompare(value: unknown): string {
1814
+ return this.normalizeHistoricalCompareValue(value);
1815
+ }
1816
+
1817
+ /**
1818
+ * Parses baseline/compareTo Steps XML into per-step Action/Expected arrays for a changed test
1819
+ * case, so the report can render readable steps instead of raw Steps XML. Best-effort: a parse
1820
+ * failure (malformed XML, shared-step lookup error) must not fail the whole comparison, so it
1821
+ * logs and falls back to leaving the caller with only the raw XML strings.
1822
+ */
1823
+ private async parseHistoricalStepsForDiff(
1824
+ testStepParserHelper: TestStepParserHelper,
1825
+ baselineStepsXml: string,
1826
+ compareToStepsXml: string,
1827
+ ): Promise<{ baselineSteps: TestSteps[]; compareToSteps: TestSteps[] } | null> {
1828
+ try {
1829
+ const emptyRevisionMap = new Map<number, number>();
1830
+ const [baselineSteps, compareToSteps] = await Promise.all([
1831
+ baselineStepsXml
1832
+ ? testStepParserHelper.parseTestSteps(baselineStepsXml, emptyRevisionMap)
1833
+ : Promise.resolve([]),
1834
+ compareToStepsXml
1835
+ ? testStepParserHelper.parseTestSteps(compareToStepsXml, emptyRevisionMap)
1836
+ : Promise.resolve([]),
1837
+ ]);
1838
+ return { baselineSteps, compareToSteps };
1839
+ } catch (err: any) {
1840
+ logger.warn(`Failed to parse historical Steps XML for diff rendering: ${err?.message || err}`);
1841
+ return null;
1842
+ }
1843
+ }
1844
+
1789
1845
  private normalizeTestPhaseValue(value: unknown): string {
1790
1846
  const rendered = this.normalizeHistoricalCompareValue(value);
1791
1847
  if (!rendered) {
@@ -1988,25 +2044,61 @@ export default class TicketsDataProvider {
1988
2044
  return /TF51535|Cannot find field/i.test(message);
1989
2045
  }
1990
2046
 
1991
- // ADO's TF51535 names the specific field it couldn't find, e.g.
1992
- // "TF51535: Cannot find field 'Elisra.TestPhase'." — different networks
1993
- // define this optional field under different reference names (Elisra.* vs
1994
- // Custom.*), so drop only the one ADO actually rejected rather than every
1995
- // optional field, or a network that defines Custom.TestPhase but not
1996
- // Elisra.TestPhase would lose Test Phase comparison entirely for no reason.
1997
- private extractHistoricalUnknownFieldName(error: any): string | null {
1998
- const message = this.historicalErrorMessage(error);
1999
- const quoted = message.match(/Cannot find field\s*'([^']+)'/i);
2000
- if (quoted?.[1]) return quoted[1];
2001
- return HISTORICAL_OPTIONAL_WORK_ITEM_FIELDS.find((field) => message.includes(field)) || null;
2047
+ // Different networks define the "Test Phase" custom field under different
2048
+ // reference names (Elisra.TestPhase vs Custom.TestPhase, or something else
2049
+ // entirely on a third network) — hardcoding candidate names and guessing is
2050
+ // inherently fragile. Resolve the project's real field list once (Fields -
2051
+ // List: GET {org}/{project}/_apis/wit/fields, supported since old TFS API
2052
+ // versions) and use whichever reference name actually matches, by
2053
+ // reference name or by friendly name. Resolves to null if the project
2054
+ // defines no such field at all.
2055
+ //
2056
+ // Caches the in-flight promise, not just the resolved value: a compare's
2057
+ // baseline and compareTo snapshots run concurrently against the same
2058
+ // provider instance, and both would otherwise read the cache before either
2059
+ // had a value to store, issuing the discovery request twice.
2060
+ private resolveHistoricalTestPhaseField(project: string, apiVersion: string | null): Promise<string | null> {
2061
+ const cached = this.historicalTestPhaseFieldByProject.get(project);
2062
+ if (cached) return cached;
2063
+ const promise = this.discoverHistoricalTestPhaseField(project, apiVersion);
2064
+ this.historicalTestPhaseFieldByProject.set(project, promise);
2065
+ return promise;
2066
+ }
2067
+
2068
+ private async discoverHistoricalTestPhaseField(
2069
+ project: string,
2070
+ apiVersion: string | null,
2071
+ ): Promise<string | null> {
2072
+ const fieldsUrl = this.appendApiVersion(`${this.orgUrl}${project}/_apis/wit/fields`, apiVersion);
2073
+ try {
2074
+ const response = await TFSServices.getItemContent(fieldsUrl, this.token);
2075
+ const fields = Array.isArray(response?.value) ? response.value : [];
2076
+ const match =
2077
+ fields.find((field: any) =>
2078
+ HISTORICAL_OPTIONAL_WORK_ITEM_FIELDS.some(
2079
+ (candidate) => String(field?.referenceName || '').toLowerCase() === candidate.toLowerCase(),
2080
+ ),
2081
+ ) || fields.find((field: any) => /test\s*phase/i.test(String(field?.name || '')));
2082
+ const resolved = match?.referenceName ? String(match.referenceName) : null;
2083
+ if (!resolved) {
2084
+ logger.warn(`[historical-workitems] no Test Phase field defined for project '${project}'`);
2085
+ }
2086
+ return resolved;
2087
+ } catch (error: any) {
2088
+ logger.warn(
2089
+ `[historical-workitems] could not list fields for project '${project}' to resolve the ` +
2090
+ `Test Phase field name, omitting it: ${this.historicalErrorMessage(error)}`,
2091
+ );
2092
+ return null;
2093
+ }
2002
2094
  }
2003
2095
 
2004
- private fetchHistoricalFieldsBatchAttempt(
2096
+ private fetchHistoricalFieldsBatch(
2005
2097
  workItemsBatchUrl: string,
2006
2098
  idChunk: number[],
2007
2099
  asOf: string,
2100
+ project: string,
2008
2101
  fieldsForRequest: string[],
2009
- attemptsRemaining: number,
2010
2102
  ): Promise<any> {
2011
2103
  return this.limit(() =>
2012
2104
  TFSServices.getItemContent(workItemsBatchUrl, this.token, 'post', {
@@ -2016,57 +2108,31 @@ export default class TicketsDataProvider {
2016
2108
  errorPolicy: 'Omit',
2017
2109
  }),
2018
2110
  ).catch((error: any) => {
2019
- if (attemptsRemaining <= 0 || !this.isHistoricalUnknownFieldError(error)) {
2020
- throw error;
2021
- }
2022
- const unknownField = this.extractHistoricalUnknownFieldName(error);
2023
- // Only strip fields this call actually recognizes as droppable
2024
- // (named explicitly, or one of the known optional fields as a
2025
- // last-resort fallback if the message doesn't parse) — never strip a
2026
- // required field, so an unrelated schema issue still surfaces as an
2027
- // error instead of silently losing data.
2028
- const reducedFields = unknownField
2029
- ? fieldsForRequest.filter((field) => field !== unknownField)
2030
- : fieldsForRequest.filter((field) => !HISTORICAL_OPTIONAL_WORK_ITEM_FIELDS.includes(field));
2031
- if (reducedFields.length === fieldsForRequest.length) {
2111
+ // Defensive net: the resolved field name came from the project's own
2112
+ // field list, so this shouldn't fire, but if it somehow still does,
2113
+ // fall back to the required fields and poison the cache so later
2114
+ // chunks/queries for this project stop requesting it too.
2115
+ const hasOptionalField = HISTORICAL_OPTIONAL_WORK_ITEM_FIELDS.some((f) => fieldsForRequest.includes(f))
2116
+ || fieldsForRequest.some((f) => !HISTORICAL_REQUIRED_WORK_ITEM_FIELDS.includes(f));
2117
+ if (!hasOptionalField || !this.isHistoricalUnknownFieldError(error)) {
2032
2118
  throw error;
2033
2119
  }
2034
- // Concurrent chunks can each discover a different bad field before any
2035
- // of them updates the shared override — intersect rather than
2036
- // overwrite, so one chunk's retry never re-requests a field another
2037
- // chunk already proved invalid for this project.
2038
- this.historicalFieldsOverride = this.historicalFieldsOverride
2039
- ? this.historicalFieldsOverride.filter((field) => reducedFields.includes(field))
2040
- : reducedFields;
2041
2120
  logger.warn(
2042
- `[historical-workitems] field${unknownField ? ` '${unknownField}'` : '(s)'} not defined ` +
2043
- `for this project; retrying workitemsbatch without it: ${this.historicalErrorMessage(error)}`,
2121
+ `[historical-workitems] resolved Test Phase field was rejected by workitemsbatch for ` +
2122
+ `project '${project}'; retrying without it: ${this.historicalErrorMessage(error)}`,
2044
2123
  );
2045
- return this.fetchHistoricalFieldsBatchAttempt(
2046
- workItemsBatchUrl,
2047
- idChunk,
2048
- asOf,
2049
- reducedFields,
2050
- attemptsRemaining - 1,
2124
+ this.historicalTestPhaseFieldByProject.set(project, Promise.resolve(null));
2125
+ return this.limit(() =>
2126
+ TFSServices.getItemContent(workItemsBatchUrl, this.token, 'post', {
2127
+ ids: idChunk,
2128
+ asOf,
2129
+ fields: HISTORICAL_REQUIRED_WORK_ITEM_FIELDS,
2130
+ errorPolicy: 'Omit',
2131
+ }),
2051
2132
  );
2052
2133
  });
2053
2134
  }
2054
2135
 
2055
- private fetchHistoricalFieldsBatch(
2056
- workItemsBatchUrl: string,
2057
- idChunk: number[],
2058
- asOf: string,
2059
- ): Promise<any> {
2060
- const fieldsForRequest = this.historicalFieldsOverride || HISTORICAL_WORK_ITEM_FIELDS;
2061
- return this.fetchHistoricalFieldsBatchAttempt(
2062
- workItemsBatchUrl,
2063
- idChunk,
2064
- asOf,
2065
- fieldsForRequest,
2066
- HISTORICAL_OPTIONAL_WORK_ITEM_FIELDS.length,
2067
- );
2068
- }
2069
-
2070
2136
  private async fetchHistoricalWorkItemsBatch(
2071
2137
  project: string,
2072
2138
  ids: number[],
@@ -2077,6 +2143,12 @@ export default class TicketsDataProvider {
2077
2143
  `${this.orgUrl}${project}/_apis/wit/workitemsbatch`,
2078
2144
  apiVersion,
2079
2145
  );
2146
+ // Resolved once per project, before any chunk starts, so every chunk
2147
+ // shares the same field list — no per-request guessing or retry race.
2148
+ const testPhaseField = await this.resolveHistoricalTestPhaseField(project, apiVersion);
2149
+ const fieldsForRequest = testPhaseField
2150
+ ? [...HISTORICAL_REQUIRED_WORK_ITEM_FIELDS, testPhaseField]
2151
+ : HISTORICAL_REQUIRED_WORK_ITEM_FIELDS;
2080
2152
  const idChunks = this.chunkHistoricalWorkItemIds(ids);
2081
2153
  try {
2082
2154
  const chunkResults = await Promise.all(
@@ -2093,7 +2165,7 @@ export default class TicketsDataProvider {
2093
2165
  // two units against the shared concurrency cap, not one — otherwise
2094
2166
  // the real ceiling on simultaneous ADO calls would silently double.
2095
2167
  Promise.all([
2096
- this.fetchHistoricalFieldsBatch(workItemsBatchUrl, idChunk, asOf),
2168
+ this.fetchHistoricalFieldsBatch(workItemsBatchUrl, idChunk, asOf, project, fieldsForRequest),
2097
2169
  this.limit(() =>
2098
2170
  TFSServices.getItemContent(workItemsBatchUrl, this.token, 'post', {
2099
2171
  ids: idChunk,
@@ -2266,8 +2338,8 @@ export default class TicketsDataProvider {
2266
2338
  iterationPath: this.normalizeHistoricalCompareValue(fields['System.IterationPath']),
2267
2339
  versionId: this.toHistoricalRevision(workItem?.rev ?? fields['System.Rev']),
2268
2340
  versionTimestamp: this.normalizeHistoricalCompareValue(fields['System.ChangedDate']),
2269
- description: this.normalizeHistoricalCompareValue(fields['System.Description']),
2270
- steps: this.normalizeHistoricalCompareValue(fields['Microsoft.VSTS.TCM.Steps']),
2341
+ description: this.rawHistoricalRichText(fields['System.Description']),
2342
+ steps: this.rawHistoricalRichText(fields['Microsoft.VSTS.TCM.Steps']),
2271
2343
  testPhase: this.normalizeTestPhaseValue(testPhaseRaw),
2272
2344
  relatedLinkCount,
2273
2345
  workItemUrl: `${this.orgUrl}${project}/_workitems/edit/${id}`,
@@ -2448,8 +2520,9 @@ export default class TicketsDataProvider {
2448
2520
  ...Array.from(compareTo.snapshotMap.keys()),
2449
2521
  ]);
2450
2522
  const sortedIds = Array.from(allIds.values()).sort((a, b) => a - b);
2523
+ const testStepParserHelper = new TestStepParserHelper(this.orgUrl, this.token);
2451
2524
 
2452
- const rows = sortedIds.map((id) => {
2525
+ const rows = await Promise.all(sortedIds.map(async (id) => {
2453
2526
  const baselineRow = baseline.snapshotMap.get(id) || null;
2454
2527
  const compareToRow = compareTo.snapshotMap.get(id) || null;
2455
2528
  const workItemType = compareToRow?.workItemType || baselineRow?.workItemType || '';
@@ -2487,7 +2560,15 @@ export default class TicketsDataProvider {
2487
2560
  const safeCompareTo = compareToRow as HistoricalWorkItemSnapshot;
2488
2561
  const changedFields: string[] = [];
2489
2562
 
2490
- if (safeBaseline.description !== safeCompareTo.description) {
2563
+ const descriptionChanged =
2564
+ this.canonicalizeHistoricalRichTextForCompare(safeBaseline.description) !==
2565
+ this.canonicalizeHistoricalRichTextForCompare(safeCompareTo.description);
2566
+ const stepsChanged =
2567
+ isTestCase &&
2568
+ this.canonicalizeHistoricalRichTextForCompare(safeBaseline.steps) !==
2569
+ this.canonicalizeHistoricalRichTextForCompare(safeCompareTo.steps);
2570
+
2571
+ if (descriptionChanged) {
2491
2572
  changedFields.push('Description');
2492
2573
  }
2493
2574
  if (safeBaseline.title !== safeCompareTo.title) {
@@ -2496,7 +2577,7 @@ export default class TicketsDataProvider {
2496
2577
  if (safeBaseline.state !== safeCompareTo.state) {
2497
2578
  changedFields.push('State');
2498
2579
  }
2499
- if (isTestCase && safeBaseline.steps !== safeCompareTo.steps) {
2580
+ if (stepsChanged) {
2500
2581
  changedFields.push('Steps');
2501
2582
  }
2502
2583
  if (safeBaseline.testPhase !== safeCompareTo.testPhase) {
@@ -2506,28 +2587,41 @@ export default class TicketsDataProvider {
2506
2587
  changedFields.push('Related Link Count');
2507
2588
  }
2508
2589
 
2509
- const differences = changedFields.map((field) => {
2510
- switch (field) {
2511
- case 'Description':
2512
- return { field, baseline: safeBaseline.description, compareTo: safeCompareTo.description };
2513
- case 'Title':
2514
- return { field, baseline: safeBaseline.title, compareTo: safeCompareTo.title };
2515
- case 'State':
2516
- return { field, baseline: safeBaseline.state, compareTo: safeCompareTo.state };
2517
- case 'Steps':
2518
- return { field, baseline: safeBaseline.steps, compareTo: safeCompareTo.steps };
2519
- case 'Test Phase':
2520
- return { field, baseline: safeBaseline.testPhase, compareTo: safeCompareTo.testPhase };
2521
- case 'Related Link Count':
2522
- return {
2523
- field,
2524
- baseline: String(safeBaseline.relatedLinkCount),
2525
- compareTo: String(safeCompareTo.relatedLinkCount),
2526
- };
2527
- default:
2528
- return { field, baseline: '', compareTo: '' };
2529
- }
2530
- });
2590
+ const differences = await Promise.all(
2591
+ changedFields.map(async (field) => {
2592
+ switch (field) {
2593
+ case 'Description':
2594
+ return { field, baseline: safeBaseline.description, compareTo: safeCompareTo.description };
2595
+ case 'Title':
2596
+ return { field, baseline: safeBaseline.title, compareTo: safeCompareTo.title };
2597
+ case 'State':
2598
+ return { field, baseline: safeBaseline.state, compareTo: safeCompareTo.state };
2599
+ case 'Steps': {
2600
+ const parsed = await this.parseHistoricalStepsForDiff(
2601
+ testStepParserHelper,
2602
+ safeBaseline.steps,
2603
+ safeCompareTo.steps,
2604
+ );
2605
+ return {
2606
+ field,
2607
+ baseline: safeBaseline.steps,
2608
+ compareTo: safeCompareTo.steps,
2609
+ ...(parsed || {}),
2610
+ };
2611
+ }
2612
+ case 'Test Phase':
2613
+ return { field, baseline: safeBaseline.testPhase, compareTo: safeCompareTo.testPhase };
2614
+ case 'Related Link Count':
2615
+ return {
2616
+ field,
2617
+ baseline: String(safeBaseline.relatedLinkCount),
2618
+ compareTo: String(safeCompareTo.relatedLinkCount),
2619
+ };
2620
+ default:
2621
+ return { field, baseline: '', compareTo: '' };
2622
+ }
2623
+ }),
2624
+ );
2531
2625
 
2532
2626
  return {
2533
2627
  id,
@@ -2540,7 +2634,7 @@ export default class TicketsDataProvider {
2540
2634
  differences,
2541
2635
  workItemUrl: safeCompareTo.workItemUrl || safeBaseline.workItemUrl,
2542
2636
  };
2543
- });
2637
+ }));
2544
2638
 
2545
2639
  const summary = rows.reduce(
2546
2640
  (acc, row) => {