@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.
- package/bin/modules/TicketsDataProvider.d.ts +24 -0
- package/bin/modules/TicketsDataProvider.js +138 -19
- package/bin/modules/TicketsDataProvider.js.map +1 -1
- package/bin/tests/modules/ticketsDataProvider.historical.test.js +204 -0
- package/bin/tests/modules/ticketsDataProvider.historical.test.js.map +1 -1
- package/package.json +1 -1
- package/src/modules/TicketsDataProvider.ts +206 -39
- package/src/tests/modules/ticketsDataProvider.historical.test.ts +238 -0
|
@@ -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');
|
|
@@ -61,7 +63,7 @@ type HistoricalSnapshotResult = {
|
|
|
61
63
|
|
|
62
64
|
const HISTORICAL_WIT_API_VERSIONS: Array<string | null> = ['7.1', '5.1', null];
|
|
63
65
|
const HISTORICAL_BATCH_MAX_IDS = 200;
|
|
64
|
-
const
|
|
66
|
+
const HISTORICAL_REQUIRED_WORK_ITEM_FIELDS = [
|
|
65
67
|
'System.Id',
|
|
66
68
|
'System.WorkItemType',
|
|
67
69
|
'System.Title',
|
|
@@ -72,8 +74,19 @@ const HISTORICAL_WORK_ITEM_FIELDS = [
|
|
|
72
74
|
'System.ChangedDate',
|
|
73
75
|
'System.Description',
|
|
74
76
|
'Microsoft.VSTS.TCM.Steps',
|
|
75
|
-
|
|
76
|
-
|
|
77
|
+
];
|
|
78
|
+
// Process-template-specific: not every project defines either of these.
|
|
79
|
+
// workitemsbatch's `fields` filter validates every requested reference name
|
|
80
|
+
// against the project's schema and throws TF51535 ("Cannot find field ...")
|
|
81
|
+
// if one is missing — unlike an unfiltered per-item GET, which just omits an
|
|
82
|
+
// unknown field from the response. toHistoricalWorkItemSnapshot already
|
|
83
|
+
// reads TestPhase via a 4-way fallback expecting it might be absent, so it's
|
|
84
|
+
// safe to drop these on a TF51535 and retry without them (see
|
|
85
|
+
// fetchHistoricalWorkItemsBatch).
|
|
86
|
+
const HISTORICAL_OPTIONAL_WORK_ITEM_FIELDS = ['Elisra.TestPhase', 'Custom.TestPhase'];
|
|
87
|
+
const HISTORICAL_WORK_ITEM_FIELDS = [
|
|
88
|
+
...HISTORICAL_REQUIRED_WORK_ITEM_FIELDS,
|
|
89
|
+
...HISTORICAL_OPTIONAL_WORK_ITEM_FIELDS,
|
|
77
90
|
];
|
|
78
91
|
|
|
79
92
|
/** Default fields fetched per work item in tree/flat query parsing. */
|
|
@@ -85,6 +98,9 @@ export default class TicketsDataProvider {
|
|
|
85
98
|
token: string = '';
|
|
86
99
|
queriesList: Array<any> = new Array<any>();
|
|
87
100
|
private limit = pLimit(10);
|
|
101
|
+
// Set once a workitemsbatch call reports one of HISTORICAL_OPTIONAL_WORK_ITEM_FIELDS
|
|
102
|
+
// as unknown for this project, so only the first chunk pays the retry cost.
|
|
103
|
+
private historicalFieldsOverride: string[] | null = null;
|
|
88
104
|
|
|
89
105
|
constructor(orgUrl: string, token: string) {
|
|
90
106
|
this.orgUrl = orgUrl;
|
|
@@ -1772,6 +1788,58 @@ export default class TicketsDataProvider {
|
|
|
1772
1788
|
return String(value).trim();
|
|
1773
1789
|
}
|
|
1774
1790
|
|
|
1791
|
+
/**
|
|
1792
|
+
* Stores rich-text fields (Description, Steps) with only whitespace trimmed, preserving markup
|
|
1793
|
+
* so the report renderer can clean/render it faithfully. Equality between two snapshots must go
|
|
1794
|
+
* through canonicalizeHistoricalRichTextForCompare instead of comparing this value directly.
|
|
1795
|
+
*/
|
|
1796
|
+
private rawHistoricalRichText(value: unknown): string {
|
|
1797
|
+
if (value == null) {
|
|
1798
|
+
return '';
|
|
1799
|
+
}
|
|
1800
|
+
if (typeof value === 'string') {
|
|
1801
|
+
return value.trim();
|
|
1802
|
+
}
|
|
1803
|
+
return this.normalizeHistoricalCompareValue(value);
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
/**
|
|
1807
|
+
* Canonicalizes a rich-text field for equality comparison only. Intentionally identical to the
|
|
1808
|
+
* whitespace collapse previously applied at storage time, so Changed/No-changes verdicts and
|
|
1809
|
+
* summary counts are unaffected by storing the raw value for rendering.
|
|
1810
|
+
*/
|
|
1811
|
+
private canonicalizeHistoricalRichTextForCompare(value: unknown): string {
|
|
1812
|
+
return this.normalizeHistoricalCompareValue(value);
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
/**
|
|
1816
|
+
* Parses baseline/compareTo Steps XML into per-step Action/Expected arrays for a changed test
|
|
1817
|
+
* case, so the report can render readable steps instead of raw Steps XML. Best-effort: a parse
|
|
1818
|
+
* failure (malformed XML, shared-step lookup error) must not fail the whole comparison, so it
|
|
1819
|
+
* logs and falls back to leaving the caller with only the raw XML strings.
|
|
1820
|
+
*/
|
|
1821
|
+
private async parseHistoricalStepsForDiff(
|
|
1822
|
+
testStepParserHelper: TestStepParserHelper,
|
|
1823
|
+
baselineStepsXml: string,
|
|
1824
|
+
compareToStepsXml: string,
|
|
1825
|
+
): Promise<{ baselineSteps: TestSteps[]; compareToSteps: TestSteps[] } | null> {
|
|
1826
|
+
try {
|
|
1827
|
+
const emptyRevisionMap = new Map<number, number>();
|
|
1828
|
+
const [baselineSteps, compareToSteps] = await Promise.all([
|
|
1829
|
+
baselineStepsXml
|
|
1830
|
+
? testStepParserHelper.parseTestSteps(baselineStepsXml, emptyRevisionMap)
|
|
1831
|
+
: Promise.resolve([]),
|
|
1832
|
+
compareToStepsXml
|
|
1833
|
+
? testStepParserHelper.parseTestSteps(compareToStepsXml, emptyRevisionMap)
|
|
1834
|
+
: Promise.resolve([]),
|
|
1835
|
+
]);
|
|
1836
|
+
return { baselineSteps, compareToSteps };
|
|
1837
|
+
} catch (err: any) {
|
|
1838
|
+
logger.warn(`Failed to parse historical Steps XML for diff rendering: ${err?.message || err}`);
|
|
1839
|
+
return null;
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1775
1843
|
private normalizeTestPhaseValue(value: unknown): string {
|
|
1776
1844
|
const rendered = this.normalizeHistoricalCompareValue(value);
|
|
1777
1845
|
if (!rendered) {
|
|
@@ -1969,6 +2037,90 @@ export default class TicketsDataProvider {
|
|
|
1969
2037
|
return status === 404 || status === 410;
|
|
1970
2038
|
}
|
|
1971
2039
|
|
|
2040
|
+
private isHistoricalUnknownFieldError(error: any): boolean {
|
|
2041
|
+
const message = this.historicalErrorMessage(error);
|
|
2042
|
+
return /TF51535|Cannot find field/i.test(message);
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
// ADO's TF51535 names the specific field it couldn't find, e.g.
|
|
2046
|
+
// "TF51535: Cannot find field 'Elisra.TestPhase'." — different networks
|
|
2047
|
+
// define this optional field under different reference names (Elisra.* vs
|
|
2048
|
+
// Custom.*), so drop only the one ADO actually rejected rather than every
|
|
2049
|
+
// optional field, or a network that defines Custom.TestPhase but not
|
|
2050
|
+
// Elisra.TestPhase would lose Test Phase comparison entirely for no reason.
|
|
2051
|
+
private extractHistoricalUnknownFieldName(error: any): string | null {
|
|
2052
|
+
const message = this.historicalErrorMessage(error);
|
|
2053
|
+
const quoted = message.match(/Cannot find field\s*'([^']+)'/i);
|
|
2054
|
+
if (quoted?.[1]) return quoted[1];
|
|
2055
|
+
return HISTORICAL_OPTIONAL_WORK_ITEM_FIELDS.find((field) => message.includes(field)) || null;
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
private fetchHistoricalFieldsBatchAttempt(
|
|
2059
|
+
workItemsBatchUrl: string,
|
|
2060
|
+
idChunk: number[],
|
|
2061
|
+
asOf: string,
|
|
2062
|
+
fieldsForRequest: string[],
|
|
2063
|
+
attemptsRemaining: number,
|
|
2064
|
+
): Promise<any> {
|
|
2065
|
+
return this.limit(() =>
|
|
2066
|
+
TFSServices.getItemContent(workItemsBatchUrl, this.token, 'post', {
|
|
2067
|
+
ids: idChunk,
|
|
2068
|
+
asOf,
|
|
2069
|
+
fields: fieldsForRequest,
|
|
2070
|
+
errorPolicy: 'Omit',
|
|
2071
|
+
}),
|
|
2072
|
+
).catch((error: any) => {
|
|
2073
|
+
if (attemptsRemaining <= 0 || !this.isHistoricalUnknownFieldError(error)) {
|
|
2074
|
+
throw error;
|
|
2075
|
+
}
|
|
2076
|
+
const unknownField = this.extractHistoricalUnknownFieldName(error);
|
|
2077
|
+
// Only strip fields this call actually recognizes as droppable
|
|
2078
|
+
// (named explicitly, or one of the known optional fields as a
|
|
2079
|
+
// last-resort fallback if the message doesn't parse) — never strip a
|
|
2080
|
+
// required field, so an unrelated schema issue still surfaces as an
|
|
2081
|
+
// error instead of silently losing data.
|
|
2082
|
+
const reducedFields = unknownField
|
|
2083
|
+
? fieldsForRequest.filter((field) => field !== unknownField)
|
|
2084
|
+
: fieldsForRequest.filter((field) => !HISTORICAL_OPTIONAL_WORK_ITEM_FIELDS.includes(field));
|
|
2085
|
+
if (reducedFields.length === fieldsForRequest.length) {
|
|
2086
|
+
throw error;
|
|
2087
|
+
}
|
|
2088
|
+
// Concurrent chunks can each discover a different bad field before any
|
|
2089
|
+
// of them updates the shared override — intersect rather than
|
|
2090
|
+
// overwrite, so one chunk's retry never re-requests a field another
|
|
2091
|
+
// chunk already proved invalid for this project.
|
|
2092
|
+
this.historicalFieldsOverride = this.historicalFieldsOverride
|
|
2093
|
+
? this.historicalFieldsOverride.filter((field) => reducedFields.includes(field))
|
|
2094
|
+
: reducedFields;
|
|
2095
|
+
logger.warn(
|
|
2096
|
+
`[historical-workitems] field${unknownField ? ` '${unknownField}'` : '(s)'} not defined ` +
|
|
2097
|
+
`for this project; retrying workitemsbatch without it: ${this.historicalErrorMessage(error)}`,
|
|
2098
|
+
);
|
|
2099
|
+
return this.fetchHistoricalFieldsBatchAttempt(
|
|
2100
|
+
workItemsBatchUrl,
|
|
2101
|
+
idChunk,
|
|
2102
|
+
asOf,
|
|
2103
|
+
reducedFields,
|
|
2104
|
+
attemptsRemaining - 1,
|
|
2105
|
+
);
|
|
2106
|
+
});
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
private fetchHistoricalFieldsBatch(
|
|
2110
|
+
workItemsBatchUrl: string,
|
|
2111
|
+
idChunk: number[],
|
|
2112
|
+
asOf: string,
|
|
2113
|
+
): Promise<any> {
|
|
2114
|
+
const fieldsForRequest = this.historicalFieldsOverride || HISTORICAL_WORK_ITEM_FIELDS;
|
|
2115
|
+
return this.fetchHistoricalFieldsBatchAttempt(
|
|
2116
|
+
workItemsBatchUrl,
|
|
2117
|
+
idChunk,
|
|
2118
|
+
asOf,
|
|
2119
|
+
fieldsForRequest,
|
|
2120
|
+
HISTORICAL_OPTIONAL_WORK_ITEM_FIELDS.length,
|
|
2121
|
+
);
|
|
2122
|
+
}
|
|
2123
|
+
|
|
1972
2124
|
private async fetchHistoricalWorkItemsBatch(
|
|
1973
2125
|
project: string,
|
|
1974
2126
|
ids: number[],
|
|
@@ -1995,14 +2147,7 @@ export default class TicketsDataProvider {
|
|
|
1995
2147
|
// two units against the shared concurrency cap, not one — otherwise
|
|
1996
2148
|
// the real ceiling on simultaneous ADO calls would silently double.
|
|
1997
2149
|
Promise.all([
|
|
1998
|
-
this.
|
|
1999
|
-
TFSServices.getItemContent(workItemsBatchUrl, this.token, 'post', {
|
|
2000
|
-
ids: idChunk,
|
|
2001
|
-
asOf,
|
|
2002
|
-
fields: HISTORICAL_WORK_ITEM_FIELDS,
|
|
2003
|
-
errorPolicy: 'Omit',
|
|
2004
|
-
}),
|
|
2005
|
-
),
|
|
2150
|
+
this.fetchHistoricalFieldsBatch(workItemsBatchUrl, idChunk, asOf),
|
|
2006
2151
|
this.limit(() =>
|
|
2007
2152
|
TFSServices.getItemContent(workItemsBatchUrl, this.token, 'post', {
|
|
2008
2153
|
ids: idChunk,
|
|
@@ -2175,8 +2320,8 @@ export default class TicketsDataProvider {
|
|
|
2175
2320
|
iterationPath: this.normalizeHistoricalCompareValue(fields['System.IterationPath']),
|
|
2176
2321
|
versionId: this.toHistoricalRevision(workItem?.rev ?? fields['System.Rev']),
|
|
2177
2322
|
versionTimestamp: this.normalizeHistoricalCompareValue(fields['System.ChangedDate']),
|
|
2178
|
-
description: this.
|
|
2179
|
-
steps: this.
|
|
2323
|
+
description: this.rawHistoricalRichText(fields['System.Description']),
|
|
2324
|
+
steps: this.rawHistoricalRichText(fields['Microsoft.VSTS.TCM.Steps']),
|
|
2180
2325
|
testPhase: this.normalizeTestPhaseValue(testPhaseRaw),
|
|
2181
2326
|
relatedLinkCount,
|
|
2182
2327
|
workItemUrl: `${this.orgUrl}${project}/_workitems/edit/${id}`,
|
|
@@ -2357,8 +2502,9 @@ export default class TicketsDataProvider {
|
|
|
2357
2502
|
...Array.from(compareTo.snapshotMap.keys()),
|
|
2358
2503
|
]);
|
|
2359
2504
|
const sortedIds = Array.from(allIds.values()).sort((a, b) => a - b);
|
|
2505
|
+
const testStepParserHelper = new TestStepParserHelper(this.orgUrl, this.token);
|
|
2360
2506
|
|
|
2361
|
-
const rows = sortedIds.map((id) => {
|
|
2507
|
+
const rows = await Promise.all(sortedIds.map(async (id) => {
|
|
2362
2508
|
const baselineRow = baseline.snapshotMap.get(id) || null;
|
|
2363
2509
|
const compareToRow = compareTo.snapshotMap.get(id) || null;
|
|
2364
2510
|
const workItemType = compareToRow?.workItemType || baselineRow?.workItemType || '';
|
|
@@ -2396,7 +2542,15 @@ export default class TicketsDataProvider {
|
|
|
2396
2542
|
const safeCompareTo = compareToRow as HistoricalWorkItemSnapshot;
|
|
2397
2543
|
const changedFields: string[] = [];
|
|
2398
2544
|
|
|
2399
|
-
|
|
2545
|
+
const descriptionChanged =
|
|
2546
|
+
this.canonicalizeHistoricalRichTextForCompare(safeBaseline.description) !==
|
|
2547
|
+
this.canonicalizeHistoricalRichTextForCompare(safeCompareTo.description);
|
|
2548
|
+
const stepsChanged =
|
|
2549
|
+
isTestCase &&
|
|
2550
|
+
this.canonicalizeHistoricalRichTextForCompare(safeBaseline.steps) !==
|
|
2551
|
+
this.canonicalizeHistoricalRichTextForCompare(safeCompareTo.steps);
|
|
2552
|
+
|
|
2553
|
+
if (descriptionChanged) {
|
|
2400
2554
|
changedFields.push('Description');
|
|
2401
2555
|
}
|
|
2402
2556
|
if (safeBaseline.title !== safeCompareTo.title) {
|
|
@@ -2405,7 +2559,7 @@ export default class TicketsDataProvider {
|
|
|
2405
2559
|
if (safeBaseline.state !== safeCompareTo.state) {
|
|
2406
2560
|
changedFields.push('State');
|
|
2407
2561
|
}
|
|
2408
|
-
if (
|
|
2562
|
+
if (stepsChanged) {
|
|
2409
2563
|
changedFields.push('Steps');
|
|
2410
2564
|
}
|
|
2411
2565
|
if (safeBaseline.testPhase !== safeCompareTo.testPhase) {
|
|
@@ -2415,28 +2569,41 @@ export default class TicketsDataProvider {
|
|
|
2415
2569
|
changedFields.push('Related Link Count');
|
|
2416
2570
|
}
|
|
2417
2571
|
|
|
2418
|
-
const differences =
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2572
|
+
const differences = await Promise.all(
|
|
2573
|
+
changedFields.map(async (field) => {
|
|
2574
|
+
switch (field) {
|
|
2575
|
+
case 'Description':
|
|
2576
|
+
return { field, baseline: safeBaseline.description, compareTo: safeCompareTo.description };
|
|
2577
|
+
case 'Title':
|
|
2578
|
+
return { field, baseline: safeBaseline.title, compareTo: safeCompareTo.title };
|
|
2579
|
+
case 'State':
|
|
2580
|
+
return { field, baseline: safeBaseline.state, compareTo: safeCompareTo.state };
|
|
2581
|
+
case 'Steps': {
|
|
2582
|
+
const parsed = await this.parseHistoricalStepsForDiff(
|
|
2583
|
+
testStepParserHelper,
|
|
2584
|
+
safeBaseline.steps,
|
|
2585
|
+
safeCompareTo.steps,
|
|
2586
|
+
);
|
|
2587
|
+
return {
|
|
2588
|
+
field,
|
|
2589
|
+
baseline: safeBaseline.steps,
|
|
2590
|
+
compareTo: safeCompareTo.steps,
|
|
2591
|
+
...(parsed || {}),
|
|
2592
|
+
};
|
|
2593
|
+
}
|
|
2594
|
+
case 'Test Phase':
|
|
2595
|
+
return { field, baseline: safeBaseline.testPhase, compareTo: safeCompareTo.testPhase };
|
|
2596
|
+
case 'Related Link Count':
|
|
2597
|
+
return {
|
|
2598
|
+
field,
|
|
2599
|
+
baseline: String(safeBaseline.relatedLinkCount),
|
|
2600
|
+
compareTo: String(safeCompareTo.relatedLinkCount),
|
|
2601
|
+
};
|
|
2602
|
+
default:
|
|
2603
|
+
return { field, baseline: '', compareTo: '' };
|
|
2604
|
+
}
|
|
2605
|
+
}),
|
|
2606
|
+
);
|
|
2440
2607
|
|
|
2441
2608
|
return {
|
|
2442
2609
|
id,
|
|
@@ -2449,7 +2616,7 @@ export default class TicketsDataProvider {
|
|
|
2449
2616
|
differences,
|
|
2450
2617
|
workItemUrl: safeCompareTo.workItemUrl || safeBaseline.workItemUrl,
|
|
2451
2618
|
};
|
|
2452
|
-
});
|
|
2619
|
+
}));
|
|
2453
2620
|
|
|
2454
2621
|
const summary = rows.reduce(
|
|
2455
2622
|
(acc, row) => {
|
|
@@ -946,6 +946,173 @@ describe('TicketsDataProvider historical queries', () => {
|
|
|
946
946
|
expect(diff).toEqual({ field: 'Related Link Count', baseline: '1', compareTo: '2' });
|
|
947
947
|
});
|
|
948
948
|
|
|
949
|
+
it('CompareHistoricalQueryResults preserves raw markup for rendering while comparing on canonicalized whitespace', async () => {
|
|
950
|
+
const baselineIso = '2026-02-01T00:00:00.000Z';
|
|
951
|
+
const compareIso = '2026-02-05T00:00:00.000Z';
|
|
952
|
+
|
|
953
|
+
(TFSServices.getItemContent as jest.Mock).mockImplementation(
|
|
954
|
+
async (url: string, _pat: string, method?: string, data?: any) => {
|
|
955
|
+
if (url.includes('/_apis/wit/queries/q-raw-html') && url.includes('api-version=7.1')) {
|
|
956
|
+
return { name: 'Raw Html Q', wiql: 'SELECT [System.Id] FROM WorkItems' };
|
|
957
|
+
}
|
|
958
|
+
if (url.includes('/_apis/wit/wiql?') && method === 'post') {
|
|
959
|
+
return { workItems: [{ id: 601 }, { id: 602 }] };
|
|
960
|
+
}
|
|
961
|
+
if (url.includes('/_apis/wit/workitemsbatch') && method === 'post') {
|
|
962
|
+
const isBaseline = data.asOf === baselineIso;
|
|
963
|
+
return {
|
|
964
|
+
value: [
|
|
965
|
+
{
|
|
966
|
+
id: 601,
|
|
967
|
+
rev: isBaseline ? 1 : 2,
|
|
968
|
+
fields: {
|
|
969
|
+
'System.WorkItemType': 'Requirement',
|
|
970
|
+
'System.Title': 'Whitespace-only edit',
|
|
971
|
+
'System.State': 'Active',
|
|
972
|
+
// Same content, differing only by internal whitespace: must compare as
|
|
973
|
+
// unchanged (identical to the pre-existing collapse-then-compare behavior)
|
|
974
|
+
// while the raw stored value keeps the original spacing for rendering.
|
|
975
|
+
'System.Description': isBaseline
|
|
976
|
+
? '<p>Hello world</p>'
|
|
977
|
+
: '<p>Hello\nworld</p>',
|
|
978
|
+
'System.ChangedDate': isBaseline ? baselineIso : compareIso,
|
|
979
|
+
},
|
|
980
|
+
},
|
|
981
|
+
{
|
|
982
|
+
id: 602,
|
|
983
|
+
rev: isBaseline ? 1 : 2,
|
|
984
|
+
fields: {
|
|
985
|
+
'System.WorkItemType': 'Requirement',
|
|
986
|
+
'System.Title': 'Real edit',
|
|
987
|
+
'System.State': 'Active',
|
|
988
|
+
'System.Description': isBaseline ? '<p>Hello world</p>' : '<p>Hello <b>world</b></p>',
|
|
989
|
+
'System.ChangedDate': isBaseline ? baselineIso : compareIso,
|
|
990
|
+
},
|
|
991
|
+
},
|
|
992
|
+
],
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
throw new Error(`unexpected URL: ${url}`);
|
|
996
|
+
},
|
|
997
|
+
);
|
|
998
|
+
|
|
999
|
+
const result = await provider.CompareHistoricalQueryResults('q-raw-html', project, baselineIso, compareIso);
|
|
1000
|
+
const byId = new Map<number, any>(result.rows.map((row: any) => [row.id, row]));
|
|
1001
|
+
|
|
1002
|
+
expect(byId.get(601)?.compareStatus).toBe('No changes');
|
|
1003
|
+
expect(byId.get(602)?.compareStatus).toBe('Changed');
|
|
1004
|
+
const diff = byId.get(602)?.differences.find((d: any) => d.field === 'Description');
|
|
1005
|
+
// Raw markup is preserved on the difference payload, not collapsed to plain text.
|
|
1006
|
+
expect(diff.baseline).toBe('<p>Hello world</p>');
|
|
1007
|
+
expect(diff.compareTo).toBe('<p>Hello <b>world</b></p>');
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
it('CompareHistoricalQueryResults attaches parsed Action/Expected steps for a changed test case', async () => {
|
|
1011
|
+
const baselineIso = '2026-02-10T00:00:00.000Z';
|
|
1012
|
+
const compareIso = '2026-02-15T00:00:00.000Z';
|
|
1013
|
+
|
|
1014
|
+
const buildStepsXml = (expected: string) =>
|
|
1015
|
+
`<steps id="0" last="1"><step id="1" type="ActionStep">` +
|
|
1016
|
+
`<parameterizedString isformatted="true">Open the app</parameterizedString>` +
|
|
1017
|
+
`<parameterizedString isformatted="true">${expected}</parameterizedString>` +
|
|
1018
|
+
`</step></steps>`;
|
|
1019
|
+
|
|
1020
|
+
(TFSServices.getItemContent as jest.Mock).mockImplementation(
|
|
1021
|
+
async (url: string, _pat: string, method?: string, data?: any) => {
|
|
1022
|
+
if (url.includes('/_apis/wit/queries/q-parsed-steps') && url.includes('api-version=7.1')) {
|
|
1023
|
+
return { name: 'Parsed Steps Q', wiql: 'SELECT [System.Id] FROM WorkItems' };
|
|
1024
|
+
}
|
|
1025
|
+
if (url.includes('/_apis/wit/wiql?') && method === 'post') {
|
|
1026
|
+
return { workItems: [{ id: 701 }] };
|
|
1027
|
+
}
|
|
1028
|
+
if (url.includes('/_apis/wit/workitemsbatch') && method === 'post') {
|
|
1029
|
+
const isBaseline = data.asOf === baselineIso;
|
|
1030
|
+
return {
|
|
1031
|
+
value: [
|
|
1032
|
+
{
|
|
1033
|
+
id: 701,
|
|
1034
|
+
rev: isBaseline ? 1 : 2,
|
|
1035
|
+
fields: {
|
|
1036
|
+
'System.WorkItemType': 'Test Case',
|
|
1037
|
+
'System.Title': 'Login test',
|
|
1038
|
+
'System.State': 'Active',
|
|
1039
|
+
'Microsoft.VSTS.TCM.Steps': buildStepsXml(isBaseline ? 'Login page shown' : 'Dashboard shown'),
|
|
1040
|
+
'System.ChangedDate': isBaseline ? baselineIso : compareIso,
|
|
1041
|
+
},
|
|
1042
|
+
},
|
|
1043
|
+
],
|
|
1044
|
+
};
|
|
1045
|
+
}
|
|
1046
|
+
throw new Error(`unexpected URL: ${url}`);
|
|
1047
|
+
},
|
|
1048
|
+
);
|
|
1049
|
+
|
|
1050
|
+
const result = await provider.CompareHistoricalQueryResults(
|
|
1051
|
+
'q-parsed-steps',
|
|
1052
|
+
project,
|
|
1053
|
+
baselineIso,
|
|
1054
|
+
compareIso,
|
|
1055
|
+
);
|
|
1056
|
+
const row = result.rows.find((r: any) => r.id === 701);
|
|
1057
|
+
expect(row?.compareStatus).toBe('Changed');
|
|
1058
|
+
expect(row?.changedFields).toContain('Steps');
|
|
1059
|
+
const diff = row?.differences.find((d: any) => d.field === 'Steps');
|
|
1060
|
+
expect(diff.baselineSteps).toEqual([
|
|
1061
|
+
expect.objectContaining({ action: 'Open the app', expected: 'Login page shown' }),
|
|
1062
|
+
]);
|
|
1063
|
+
expect(diff.compareToSteps).toEqual([
|
|
1064
|
+
expect.objectContaining({ action: 'Open the app', expected: 'Dashboard shown' }),
|
|
1065
|
+
]);
|
|
1066
|
+
});
|
|
1067
|
+
|
|
1068
|
+
it('CompareHistoricalQueryResults falls back gracefully when Steps XML cannot be parsed', async () => {
|
|
1069
|
+
const baselineIso = '2026-02-20T00:00:00.000Z';
|
|
1070
|
+
const compareIso = '2026-02-25T00:00:00.000Z';
|
|
1071
|
+
|
|
1072
|
+
(TFSServices.getItemContent as jest.Mock).mockImplementation(
|
|
1073
|
+
async (url: string, _pat: string, method?: string, data?: any) => {
|
|
1074
|
+
if (url.includes('/_apis/wit/queries/q-bad-steps') && url.includes('api-version=7.1')) {
|
|
1075
|
+
return { name: 'Bad Steps Q', wiql: 'SELECT [System.Id] FROM WorkItems' };
|
|
1076
|
+
}
|
|
1077
|
+
if (url.includes('/_apis/wit/wiql?') && method === 'post') {
|
|
1078
|
+
return { workItems: [{ id: 801 }] };
|
|
1079
|
+
}
|
|
1080
|
+
if (url.includes('/_apis/wit/workitemsbatch') && method === 'post') {
|
|
1081
|
+
const isBaseline = data.asOf === baselineIso;
|
|
1082
|
+
return {
|
|
1083
|
+
value: [
|
|
1084
|
+
{
|
|
1085
|
+
id: 801,
|
|
1086
|
+
rev: isBaseline ? 1 : 2,
|
|
1087
|
+
fields: {
|
|
1088
|
+
'System.WorkItemType': 'Test Case',
|
|
1089
|
+
'System.Title': 'Malformed steps case',
|
|
1090
|
+
'System.State': 'Active',
|
|
1091
|
+
'Microsoft.VSTS.TCM.Steps': isBaseline ? '<steps><unclosed>' : '<steps><unclosed2>',
|
|
1092
|
+
'System.ChangedDate': isBaseline ? baselineIso : compareIso,
|
|
1093
|
+
},
|
|
1094
|
+
},
|
|
1095
|
+
],
|
|
1096
|
+
};
|
|
1097
|
+
}
|
|
1098
|
+
throw new Error(`unexpected URL: ${url}`);
|
|
1099
|
+
},
|
|
1100
|
+
);
|
|
1101
|
+
|
|
1102
|
+
const result = await provider.CompareHistoricalQueryResults('q-bad-steps', project, baselineIso, compareIso);
|
|
1103
|
+
const row = result.rows.find((r: any) => r.id === 801);
|
|
1104
|
+
expect(row?.compareStatus).toBe('Changed');
|
|
1105
|
+
expect(row?.changedFields).toContain('Steps');
|
|
1106
|
+
const diff = row?.differences.find((d: any) => d.field === 'Steps');
|
|
1107
|
+
expect(diff.baseline).toBe('<steps><unclosed>');
|
|
1108
|
+
expect(diff.compareTo).toBe('<steps><unclosed2>');
|
|
1109
|
+
// TestStepParserHelper.parseTestSteps already swallows its own parse errors and resolves to
|
|
1110
|
+
// an empty list, so the diff carries empty parsed-steps arrays rather than raw XML garbage;
|
|
1111
|
+
// the content-control adapter treats an empty array the same as "no parsed steps".
|
|
1112
|
+
expect(diff.baselineSteps).toEqual([]);
|
|
1113
|
+
expect(diff.compareToSteps).toEqual([]);
|
|
1114
|
+
});
|
|
1115
|
+
|
|
949
1116
|
it('GetHistoricalQueryResults treats a workitemsbatch errorPolicy Omit as skipped without a per-item fallback', async () => {
|
|
950
1117
|
const asOfIso = '2026-01-15T00:00:00.000Z';
|
|
951
1118
|
|
|
@@ -994,4 +1161,75 @@ describe('TicketsDataProvider historical queries', () => {
|
|
|
994
1161
|
(logger.warn as jest.Mock).mock.calls.some((call) => String(call[0]).includes('omitted 1')),
|
|
995
1162
|
).toBe(true);
|
|
996
1163
|
});
|
|
1164
|
+
|
|
1165
|
+
it('GetHistoricalQueryResults drops only the specific field ADO names in TF51535, keeping Custom.TestPhase when only Elisra.TestPhase is undefined for this project', async () => {
|
|
1166
|
+
const asOfIso = '2026-02-01T00:00:00.000Z';
|
|
1167
|
+
// 205 ids -> 2 chunks (200 + 5), both dispatched with the full field list
|
|
1168
|
+
// near-simultaneously — this proves the retry is decided per-call, not
|
|
1169
|
+
// from the shared override flag, since both chunks fail before either
|
|
1170
|
+
// one sets it.
|
|
1171
|
+
const allIds = Array.from({ length: 205 }, (_, idx) => idx + 1);
|
|
1172
|
+
|
|
1173
|
+
(TFSServices.getItemContent as jest.Mock).mockImplementation(
|
|
1174
|
+
async (url: string, _pat: string, method?: string, data?: any) => {
|
|
1175
|
+
if (url.includes('/_apis/wit/queries/q-unknown-field') && url.includes('api-version=7.1')) {
|
|
1176
|
+
return { name: 'Unknown Field Q', wiql: 'SELECT [System.Id] FROM WorkItems' };
|
|
1177
|
+
}
|
|
1178
|
+
if (url.includes('/_apis/wit/wiql?') && method === 'post') {
|
|
1179
|
+
return { workItems: allIds.map((id) => ({ id })) };
|
|
1180
|
+
}
|
|
1181
|
+
if (url.includes('/_apis/wit/workitemsbatch') && method === 'post') {
|
|
1182
|
+
if (data?.$expand === 'Relations') {
|
|
1183
|
+
return {
|
|
1184
|
+
value: (Array.isArray(data?.ids) ? data.ids : []).map((id: number) => ({ id, relations: [] })),
|
|
1185
|
+
};
|
|
1186
|
+
}
|
|
1187
|
+
if (Array.isArray(data?.fields) && data.fields.includes('Elisra.TestPhase')) {
|
|
1188
|
+
throw {
|
|
1189
|
+
response: {
|
|
1190
|
+
status: 400,
|
|
1191
|
+
data: { message: "TF51535: Cannot find field 'Elisra.TestPhase'." },
|
|
1192
|
+
},
|
|
1193
|
+
};
|
|
1194
|
+
}
|
|
1195
|
+
// Custom.TestPhase is the real field on this project and must
|
|
1196
|
+
// still be requested — only the unrecognized alias should be
|
|
1197
|
+
// dropped, not every optional field.
|
|
1198
|
+
expect(data.fields).toEqual(expect.arrayContaining(['Custom.TestPhase']));
|
|
1199
|
+
expect(data.fields).not.toEqual(expect.arrayContaining(['Elisra.TestPhase']));
|
|
1200
|
+
return {
|
|
1201
|
+
value: (Array.isArray(data?.ids) ? data.ids : []).map((id: number) => ({
|
|
1202
|
+
id,
|
|
1203
|
+
rev: 1,
|
|
1204
|
+
fields: {
|
|
1205
|
+
'System.WorkItemType': 'Test Case',
|
|
1206
|
+
'System.Title': `Test ${id}`,
|
|
1207
|
+
'System.State': 'Active',
|
|
1208
|
+
'System.ChangedDate': asOfIso,
|
|
1209
|
+
'Custom.TestPhase': 'FAT',
|
|
1210
|
+
},
|
|
1211
|
+
})),
|
|
1212
|
+
};
|
|
1213
|
+
}
|
|
1214
|
+
throw new Error(`unexpected URL: ${url}`);
|
|
1215
|
+
},
|
|
1216
|
+
);
|
|
1217
|
+
|
|
1218
|
+
const result = await provider.GetHistoricalQueryResults('q-unknown-field', project, asOfIso);
|
|
1219
|
+
|
|
1220
|
+
// Every id came back — nothing fell to the 2xN per-item fallback, which
|
|
1221
|
+
// would have thrown on any call not covered by the handler above.
|
|
1222
|
+
expect(result.total).toBe(205);
|
|
1223
|
+
const fieldsCallsWithElisraAlias = (TFSServices.getItemContent as jest.Mock).mock.calls.filter(
|
|
1224
|
+
(call) => Array.isArray(call[3]?.fields) && call[3].fields.includes('Elisra.TestPhase'),
|
|
1225
|
+
);
|
|
1226
|
+
// Both chunks start with the full field list before the override is set,
|
|
1227
|
+
// so both legitimately fail once and both must retry — not just one.
|
|
1228
|
+
expect(fieldsCallsWithElisraAlias).toHaveLength(2);
|
|
1229
|
+
expect(
|
|
1230
|
+
(logger.warn as jest.Mock).mock.calls.some((call) =>
|
|
1231
|
+
String(call[0]).includes("field 'Elisra.TestPhase' not defined for this project"),
|
|
1232
|
+
),
|
|
1233
|
+
).toBe(true);
|
|
1234
|
+
});
|
|
997
1235
|
});
|