@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
|
@@ -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
|
-
//
|
|
100
|
-
//
|
|
101
|
-
|
|
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
|
-
//
|
|
1992
|
-
//
|
|
1993
|
-
//
|
|
1994
|
-
//
|
|
1995
|
-
//
|
|
1996
|
-
//
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
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
|
|
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
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
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]
|
|
2043
|
-
`
|
|
2121
|
+
`[historical-workitems] resolved Test Phase field was rejected by workitemsbatch for ` +
|
|
2122
|
+
`project '${project}'; retrying without it: ${this.historicalErrorMessage(error)}`,
|
|
2044
2123
|
);
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
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.
|
|
2270
|
-
steps: this.
|
|
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
|
-
|
|
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 (
|
|
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 =
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
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) => {
|