@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
|
@@ -10,7 +10,7 @@ export default class TicketsDataProvider {
|
|
|
10
10
|
token: string;
|
|
11
11
|
queriesList: Array<any>;
|
|
12
12
|
private limit;
|
|
13
|
-
private
|
|
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
|
|
220
|
-
private
|
|
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
|
-
//
|
|
49
|
-
//
|
|
50
|
-
|
|
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
|
-
//
|
|
1593
|
-
//
|
|
1594
|
-
//
|
|
1595
|
-
//
|
|
1596
|
-
//
|
|
1597
|
-
//
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
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
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
:
|
|
1634
|
-
|
|
1635
|
-
|
|
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.
|
|
1784
|
-
steps: this.
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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;
|