@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.
@@ -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 HISTORICAL_WORK_ITEM_FIELDS = [
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
- 'Elisra.TestPhase',
76
- 'Custom.TestPhase',
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.limit(() =>
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.normalizeHistoricalCompareValue(fields['System.Description']),
2179
- steps: this.normalizeHistoricalCompareValue(fields['Microsoft.VSTS.TCM.Steps']),
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
- if (safeBaseline.description !== safeCompareTo.description) {
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 (isTestCase && safeBaseline.steps !== safeCompareTo.steps) {
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 = changedFields.map((field) => {
2419
- switch (field) {
2420
- case 'Description':
2421
- return { field, baseline: safeBaseline.description, compareTo: safeCompareTo.description };
2422
- case 'Title':
2423
- return { field, baseline: safeBaseline.title, compareTo: safeCompareTo.title };
2424
- case 'State':
2425
- return { field, baseline: safeBaseline.state, compareTo: safeCompareTo.state };
2426
- case 'Steps':
2427
- return { field, baseline: safeBaseline.steps, compareTo: safeCompareTo.steps };
2428
- case 'Test Phase':
2429
- return { field, baseline: safeBaseline.testPhase, compareTo: safeCompareTo.testPhase };
2430
- case 'Related Link Count':
2431
- return {
2432
- field,
2433
- baseline: String(safeBaseline.relatedLinkCount),
2434
- compareTo: String(safeCompareTo.relatedLinkCount),
2435
- };
2436
- default:
2437
- return { field, baseline: '', compareTo: '' };
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
  });