@elisra-devops/docgen-data-provider 1.139.0 → 1.141.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.
Files changed (48) hide show
  1. package/.github/workflows/ci.yml +2 -0
  2. package/bin/helpers/tfs.js +21 -14
  3. package/bin/helpers/tfs.js.map +1 -1
  4. package/bin/modules/JfrogDataProvider.js +1 -1
  5. package/bin/modules/JfrogDataProvider.js.map +1 -1
  6. package/bin/modules/PipelinesDataProvider.js +1 -1
  7. package/bin/modules/PipelinesDataProvider.js.map +1 -1
  8. package/bin/modules/ResultDataProvider.js +21 -15
  9. package/bin/modules/ResultDataProvider.js.map +1 -1
  10. package/bin/modules/TicketsDataProvider.d.ts +3 -3
  11. package/bin/modules/TicketsDataProvider.js +80 -54
  12. package/bin/modules/TicketsDataProvider.js.map +1 -1
  13. package/bin/tests/modules/JfrogDataProvider.test.js +3 -1
  14. package/bin/tests/modules/JfrogDataProvider.test.js.map +1 -1
  15. package/bin/tests/modules/ResultDataProvider.test.js +6 -2
  16. package/bin/tests/modules/ResultDataProvider.test.js.map +1 -1
  17. package/bin/tests/modules/ticketsDataProvider.historical.test.js +111 -42
  18. package/bin/tests/modules/ticketsDataProvider.historical.test.js.map +1 -1
  19. package/bin/tests/utils/logger.test.d.ts +1 -0
  20. package/bin/tests/utils/logger.test.js +238 -0
  21. package/bin/tests/utils/logger.test.js.map +1 -0
  22. package/bin/tests/utils/runContext.test.d.ts +1 -0
  23. package/bin/tests/utils/runContext.test.js +51 -0
  24. package/bin/tests/utils/runContext.test.js.map +1 -0
  25. package/bin/utils/logSink.d.ts +25 -0
  26. package/bin/utils/logSink.js +16 -0
  27. package/bin/utils/logSink.js.map +1 -0
  28. package/bin/utils/logger.d.ts +6 -0
  29. package/bin/utils/logger.js +219 -27
  30. package/bin/utils/logger.js.map +1 -1
  31. package/bin/utils/runContext.d.ts +8 -0
  32. package/bin/utils/runContext.js +12 -0
  33. package/bin/utils/runContext.js.map +1 -0
  34. package/eslint.config.mjs +40 -0
  35. package/package.json +8 -3
  36. package/src/helpers/tfs.ts +19 -15
  37. package/src/modules/JfrogDataProvider.ts +1 -1
  38. package/src/modules/PipelinesDataProvider.ts +1 -1
  39. package/src/modules/ResultDataProvider.ts +20 -15
  40. package/src/modules/TicketsDataProvider.ts +93 -74
  41. package/src/tests/modules/JfrogDataProvider.test.ts +3 -1
  42. package/src/tests/modules/ResultDataProvider.test.ts +6 -2
  43. package/src/tests/modules/ticketsDataProvider.historical.test.ts +128 -43
  44. package/src/tests/utils/logger.test.ts +270 -0
  45. package/src/tests/utils/runContext.test.ts +48 -0
  46. package/src/utils/logSink.ts +44 -0
  47. package/src/utils/logger.ts +229 -29
  48. package/src/utils/runContext.ts +25 -0
@@ -259,10 +259,22 @@ export default class ResultDataProvider {
259
259
 
260
260
  return { combinedResults, openPcrToTestCaseTraceMap, testCaseToOpenPcrTraceMap };
261
261
  } catch (error: any) {
262
- logger.error(`Error during getCombinedResultsSummary: ${error.message}`);
262
+ // One event, one record — this used to be two separate calls (message, then
263
+ // conditionally the response body), which interleave with other requests' output
264
+ // under concurrency and become two unrelated entries in the dashboard's error grouping.
265
+ let responseSummary: string | undefined;
263
266
  if (error.response) {
264
- logger.error(`Response Data: ${JSON.stringify(error.response.data)}`);
267
+ // Truncated to match the bound already used for this in tfs.ts — an untruncated ADO
268
+ // response body isn't a credential, but it can still be large/sensitive work-item content
269
+ // and doesn't need to go into logs unbounded. JSON.stringify itself (not just the
270
+ // output) can throw on a circular value, so that has to be guarded too, not just capped.
271
+ try {
272
+ responseSummary = JSON.stringify(error.response.data).substring(0, 200);
273
+ } catch {
274
+ responseSummary = '[unserializable response data]';
275
+ }
265
276
  }
277
+ logger.error(`Error during getCombinedResultsSummary: ${error.message}`, { responseSummary });
266
278
  // Ensure the error is rethrown to propagate it correctly
267
279
  throw new Error(error.message || 'Unknown error occurred during getCombinedResultsSummary');
268
280
  }
@@ -3261,8 +3273,7 @@ export default class ResultDataProvider {
3261
3273
  );
3262
3274
  return detailedPoints;
3263
3275
  } catch (err: any) {
3264
- logger.error(`Error during fetching Cross Test Points: ${err.message}`);
3265
- logger.error(`Error stack: ${err.stack}`);
3276
+ logger.error('Error during fetching Cross Test Points', err);
3266
3277
  return [];
3267
3278
  }
3268
3279
  }
@@ -3903,10 +3914,7 @@ export default class ResultDataProvider {
3903
3914
  relatedCRs,
3904
3915
  };
3905
3916
  } catch (error: any) {
3906
- logger.error(`Error while fetching run result: ${error.message}`);
3907
- if (isTestReporter) {
3908
- logger.error(`Error stack: ${error.stack}`);
3909
- }
3917
+ logger.error('Error while fetching run result', error);
3910
3918
  return null;
3911
3919
  }
3912
3920
  }
@@ -4595,7 +4603,7 @@ export default class ResultDataProvider {
4595
4603
  ? testCase.workItem.workItemFields
4596
4604
  : [];
4597
4605
  if (testCaseWorkItemFields.length === 0) {
4598
- logger.warn(`Could not fetch the steps from WI ${JSON.stringify(testCase.workItem.id)}`);
4606
+ logger.warn(`Could not fetch the steps from WI ${testCase.workItem.id}`);
4599
4607
  if (!isTestReporter) {
4600
4608
  continue;
4601
4609
  }
@@ -5058,8 +5066,7 @@ export default class ResultDataProvider {
5058
5066
  ? createResponseObject(resultData, testSuiteId, point, ...additionalArgs)
5059
5067
  : null;
5060
5068
  } catch (error: any) {
5061
- logger.error(`Error occurred for point ${point.testCaseId}: ${error.message}`);
5062
- logger.error(`Stack trace: ${error.stack}`);
5069
+ logger.error(`Error occurred for point ${point.testCaseId}`, error);
5063
5070
  return null;
5064
5071
  }
5065
5072
  }
@@ -5153,8 +5160,7 @@ export default class ResultDataProvider {
5153
5160
  summarizedItemMap.set(wi.id, mappedItem);
5154
5161
  }
5155
5162
  } catch (error: any) {
5156
- logger.error(`Error occurred while fetching linked work items: ${error.message}`);
5157
- logger.error(`Error Stack: ${error.stack}`);
5163
+ logger.error('Error occurred while fetching linked work items', error);
5158
5164
  }
5159
5165
  }
5160
5166
 
@@ -5826,8 +5832,7 @@ export default class ResultDataProvider {
5826
5832
  }
5827
5833
  return resultDataResponse;
5828
5834
  } catch (err: any) {
5829
- logger.error(`Error occurred while fetching result data: ${err.message}`);
5830
- logger.error(`Error stack: ${err.stack}`);
5835
+ logger.error('Error occurred while fetching result data', err);
5831
5836
  return null;
5832
5837
  }
5833
5838
  },
@@ -98,9 +98,11 @@ export default class TicketsDataProvider {
98
98
  token: string = '';
99
99
  queriesList: Array<any> = new Array<any>();
100
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;
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>>();
104
106
 
105
107
  constructor(orgUrl: string, token: string) {
106
108
  this.orgUrl = orgUrl;
@@ -781,11 +783,9 @@ export default class TicketsDataProvider {
781
783
  : null;
782
784
  } catch (err: any) {
783
785
  logger.error(
784
- `Error occurred while constructing the customer query list ${err.message} with query ${JSON.stringify(
785
- rootQuery,
786
- )}`,
786
+ `Error occurred while constructing the customer query list with query ${JSON.stringify(rootQuery)}`,
787
+ err,
787
788
  );
788
- logger.error(`Error stack ${err.message}`);
789
789
  return null;
790
790
  }
791
791
  }
@@ -2042,25 +2042,61 @@ export default class TicketsDataProvider {
2042
2042
  return /TF51535|Cannot find field/i.test(message);
2043
2043
  }
2044
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;
2045
+ // Different networks define the "Test Phase" custom field under different
2046
+ // reference names (Elisra.TestPhase vs Custom.TestPhase, or something else
2047
+ // entirely on a third network) — hardcoding candidate names and guessing is
2048
+ // inherently fragile. Resolve the project's real field list once (Fields -
2049
+ // List: GET {org}/{project}/_apis/wit/fields, supported since old TFS API
2050
+ // versions) and use whichever reference name actually matches, by
2051
+ // reference name or by friendly name. Resolves to null if the project
2052
+ // defines no such field at all.
2053
+ //
2054
+ // Caches the in-flight promise, not just the resolved value: a compare's
2055
+ // baseline and compareTo snapshots run concurrently against the same
2056
+ // provider instance, and both would otherwise read the cache before either
2057
+ // had a value to store, issuing the discovery request twice.
2058
+ private resolveHistoricalTestPhaseField(project: string, apiVersion: string | null): Promise<string | null> {
2059
+ const cached = this.historicalTestPhaseFieldByProject.get(project);
2060
+ if (cached) return cached;
2061
+ const promise = this.discoverHistoricalTestPhaseField(project, apiVersion);
2062
+ this.historicalTestPhaseFieldByProject.set(project, promise);
2063
+ return promise;
2064
+ }
2065
+
2066
+ private async discoverHistoricalTestPhaseField(
2067
+ project: string,
2068
+ apiVersion: string | null,
2069
+ ): Promise<string | null> {
2070
+ const fieldsUrl = this.appendApiVersion(`${this.orgUrl}${project}/_apis/wit/fields`, apiVersion);
2071
+ try {
2072
+ const response = await TFSServices.getItemContent(fieldsUrl, this.token);
2073
+ const fields = Array.isArray(response?.value) ? response.value : [];
2074
+ const match =
2075
+ fields.find((field: any) =>
2076
+ HISTORICAL_OPTIONAL_WORK_ITEM_FIELDS.some(
2077
+ (candidate) => String(field?.referenceName || '').toLowerCase() === candidate.toLowerCase(),
2078
+ ),
2079
+ ) || fields.find((field: any) => /test\s*phase/i.test(String(field?.name || '')));
2080
+ const resolved = match?.referenceName ? String(match.referenceName) : null;
2081
+ if (!resolved) {
2082
+ logger.warn(`[historical-workitems] no Test Phase field defined for project '${project}'`);
2083
+ }
2084
+ return resolved;
2085
+ } catch (error: any) {
2086
+ logger.warn(
2087
+ `[historical-workitems] could not list fields for project '${project}' to resolve the ` +
2088
+ `Test Phase field name, omitting it: ${this.historicalErrorMessage(error)}`,
2089
+ );
2090
+ return null;
2091
+ }
2056
2092
  }
2057
2093
 
2058
- private fetchHistoricalFieldsBatchAttempt(
2094
+ private fetchHistoricalFieldsBatch(
2059
2095
  workItemsBatchUrl: string,
2060
2096
  idChunk: number[],
2061
2097
  asOf: string,
2098
+ project: string,
2062
2099
  fieldsForRequest: string[],
2063
- attemptsRemaining: number,
2064
2100
  ): Promise<any> {
2065
2101
  return this.limit(() =>
2066
2102
  TFSServices.getItemContent(workItemsBatchUrl, this.token, 'post', {
@@ -2070,57 +2106,31 @@ export default class TicketsDataProvider {
2070
2106
  errorPolicy: 'Omit',
2071
2107
  }),
2072
2108
  ).catch((error: any) => {
2073
- if (attemptsRemaining <= 0 || !this.isHistoricalUnknownFieldError(error)) {
2109
+ // Defensive net: the resolved field name came from the project's own
2110
+ // field list, so this shouldn't fire, but if it somehow still does,
2111
+ // fall back to the required fields and poison the cache so later
2112
+ // chunks/queries for this project stop requesting it too.
2113
+ const hasOptionalField = HISTORICAL_OPTIONAL_WORK_ITEM_FIELDS.some((f) => fieldsForRequest.includes(f))
2114
+ || fieldsForRequest.some((f) => !HISTORICAL_REQUIRED_WORK_ITEM_FIELDS.includes(f));
2115
+ if (!hasOptionalField || !this.isHistoricalUnknownFieldError(error)) {
2074
2116
  throw error;
2075
2117
  }
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
2118
  logger.warn(
2096
- `[historical-workitems] field${unknownField ? ` '${unknownField}'` : '(s)'} not defined ` +
2097
- `for this project; retrying workitemsbatch without it: ${this.historicalErrorMessage(error)}`,
2119
+ `[historical-workitems] resolved Test Phase field was rejected by workitemsbatch for ` +
2120
+ `project '${project}'; retrying without it: ${this.historicalErrorMessage(error)}`,
2098
2121
  );
2099
- return this.fetchHistoricalFieldsBatchAttempt(
2100
- workItemsBatchUrl,
2101
- idChunk,
2102
- asOf,
2103
- reducedFields,
2104
- attemptsRemaining - 1,
2122
+ this.historicalTestPhaseFieldByProject.set(project, Promise.resolve(null));
2123
+ return this.limit(() =>
2124
+ TFSServices.getItemContent(workItemsBatchUrl, this.token, 'post', {
2125
+ ids: idChunk,
2126
+ asOf,
2127
+ fields: HISTORICAL_REQUIRED_WORK_ITEM_FIELDS,
2128
+ errorPolicy: 'Omit',
2129
+ }),
2105
2130
  );
2106
2131
  });
2107
2132
  }
2108
2133
 
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
-
2124
2134
  private async fetchHistoricalWorkItemsBatch(
2125
2135
  project: string,
2126
2136
  ids: number[],
@@ -2131,6 +2141,12 @@ export default class TicketsDataProvider {
2131
2141
  `${this.orgUrl}${project}/_apis/wit/workitemsbatch`,
2132
2142
  apiVersion,
2133
2143
  );
2144
+ // Resolved once per project, before any chunk starts, so every chunk
2145
+ // shares the same field list — no per-request guessing or retry race.
2146
+ const testPhaseField = await this.resolveHistoricalTestPhaseField(project, apiVersion);
2147
+ const fieldsForRequest = testPhaseField
2148
+ ? [...HISTORICAL_REQUIRED_WORK_ITEM_FIELDS, testPhaseField]
2149
+ : HISTORICAL_REQUIRED_WORK_ITEM_FIELDS;
2134
2150
  const idChunks = this.chunkHistoricalWorkItemIds(ids);
2135
2151
  try {
2136
2152
  const chunkResults = await Promise.all(
@@ -2147,7 +2163,7 @@ export default class TicketsDataProvider {
2147
2163
  // two units against the shared concurrency cap, not one — otherwise
2148
2164
  // the real ceiling on simultaneous ADO calls would silently double.
2149
2165
  Promise.all([
2150
- this.fetchHistoricalFieldsBatch(workItemsBatchUrl, idChunk, asOf),
2166
+ this.fetchHistoricalFieldsBatch(workItemsBatchUrl, idChunk, asOf, project, fieldsForRequest),
2151
2167
  this.limit(() =>
2152
2168
  TFSServices.getItemContent(workItemsBatchUrl, this.token, 'post', {
2153
2169
  ids: idChunk,
@@ -2679,8 +2695,10 @@ export default class TicketsDataProvider {
2679
2695
  const subRes = await postBatch(currentIds);
2680
2696
  res.push(...subRes.value);
2681
2697
  } catch (error) {
2682
- logger.error(`error populating workitems array`);
2683
- logger.error(JSON.stringify(error));
2698
+ // JSON.stringify(error) on an AxiosError calls its own .toJSON(), which serializes
2699
+ // config.auth.password / config.headers.Authorization — the PAT, in plaintext. Pass
2700
+ // the error as meta instead and let the logger's redact format scrub it.
2701
+ logger.error(`error populating workitems array`, error);
2684
2702
  return [];
2685
2703
  }
2686
2704
  }
@@ -2690,8 +2708,10 @@ export default class TicketsDataProvider {
2690
2708
  const subRes = await postBatch(currentIds);
2691
2709
  res.push(...subRes.value);
2692
2710
  } catch (error) {
2693
- logger.error(`error populating workitems array`);
2694
- logger.error(JSON.stringify(error));
2711
+ // JSON.stringify(error) on an AxiosError calls its own .toJSON(), which serializes
2712
+ // config.auth.password / config.headers.Authorization — the PAT, in plaintext. Pass
2713
+ // the error as meta instead and let the logger's redact format scrub it.
2714
+ logger.error(`error populating workitems array`, error);
2695
2715
  return [];
2696
2716
  }
2697
2717
  }
@@ -2825,8 +2845,9 @@ export default class TicketsDataProvider {
2825
2845
  );
2826
2846
  return attachmentList;
2827
2847
  } catch (e) {
2828
- logger.error(`error fetching attachments for work item ${id}`);
2829
- logger.error(`${JSON.stringify(e)}`);
2848
+ // JSON.stringify(e) on an Error returns "{}" — own enumerable props only, message/stack
2849
+ // are non-enumerable — so this logged literally nothing useful. Pass it as meta instead.
2850
+ logger.error(`error fetching attachments for work item ${id}`, e);
2830
2851
  return [];
2831
2852
  }
2832
2853
  }
@@ -3143,11 +3164,9 @@ export default class TicketsDataProvider {
3143
3164
  return { tree1: tree1Node, tree2: tree2Node };
3144
3165
  } catch (err: any) {
3145
3166
  logger.error(
3146
- `Error occurred while constructing the query list ${err.message} with query ${JSON.stringify(
3147
- rootQuery,
3148
- )}`,
3167
+ `Error occurred while constructing the query list with query ${JSON.stringify(rootQuery)}`,
3168
+ err,
3149
3169
  );
3150
- logger.error(`Error stack ${err.message}`);
3151
3170
  }
3152
3171
  }
3153
3172
 
@@ -36,7 +36,9 @@ describe('JfrogDataProvider', () => {
36
36
  `${mockOrgUrl}${mockTeamProject}/_apis/serviceendpoint/endpoints/${mockConnectionId}?api-version=7.1`,
37
37
  mockTfsToken
38
38
  );
39
- expect(logger.debug).toHaveBeenCalledWith(`service connection url "${mockResponse.url}"`);
39
+ // The log used JSON.stringify(url), which wraps a plain string in quotes for no reason —
40
+ // fixed to interpolate the string directly (see docgen-log-redaction / Phase 2).
41
+ expect(logger.debug).toHaveBeenCalledWith(`service connection url ${mockResponse.url}`);
40
42
  expect(result).toBe(mockResponse.url);
41
43
  });
42
44
 
@@ -6027,8 +6027,12 @@ describe('ResultDataProvider', () => {
6027
6027
  resultDataProvider.getCombinedResultsSummary(mockTestPlanId, mockProjectName)
6028
6028
  ).rejects.toThrow('boom');
6029
6029
 
6030
- expect(logger.error).toHaveBeenCalledWith('Error during getCombinedResultsSummary: boom');
6031
- expect(logger.error).toHaveBeenCalledWith('Response Data: {"detail":"bad"}');
6030
+ // Was two separate calls (message, then the response body) — consolidated into one
6031
+ // record with the response summary as meta (docgen-log-redaction / Phase 2/4).
6032
+ expect(logger.error).toHaveBeenCalledWith('Error during getCombinedResultsSummary: boom', {
6033
+ responseSummary: '{"detail":"bad"}',
6034
+ });
6035
+ expect(logger.error).toHaveBeenCalledTimes(1);
6032
6036
  });
6033
6037
  });
6034
6038
 
@@ -1162,73 +1162,158 @@ describe('TicketsDataProvider historical queries', () => {
1162
1162
  ).toBe(true);
1163
1163
  });
1164
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 () => {
1165
+ it('GetHistoricalQueryResults resolves the real Test Phase reference name from the project\'s own field list instead of guessing', async () => {
1166
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
1167
 
1173
1168
  (TFSServices.getItemContent as jest.Mock).mockImplementation(
1174
1169
  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' };
1170
+ if (url.includes('/_apis/wit/queries/q-resolve-field') && url.includes('api-version=7.1')) {
1171
+ return { name: 'Resolve Field Q', wiql: 'SELECT [System.Id] FROM WorkItems' };
1177
1172
  }
1178
1173
  if (url.includes('/_apis/wit/wiql?') && method === 'post') {
1179
- return { workItems: allIds.map((id) => ({ id })) };
1174
+ return { workItems: [{ id: 501 }] };
1175
+ }
1176
+ if (url.endsWith('/_apis/wit/fields?api-version=7.1')) {
1177
+ // Neither Elisra.TestPhase nor Custom.TestPhase — a third,
1178
+ // project-specific reference name, discovered only by friendly
1179
+ // name ("Test Phase").
1180
+ return {
1181
+ value: [
1182
+ { name: 'Title', referenceName: 'System.Title' },
1183
+ { name: 'Test Phase', referenceName: 'MyCompany.QaPhase' },
1184
+ ],
1185
+ };
1180
1186
  }
1181
1187
  if (url.includes('/_apis/wit/workitemsbatch') && method === 'post') {
1182
1188
  if (data?.$expand === 'Relations') {
1183
- return {
1184
- value: (Array.isArray(data?.ids) ? data.ids : []).map((id: number) => ({ id, relations: [] })),
1185
- };
1189
+ return { value: [{ id: 501, relations: [] }] };
1186
1190
  }
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'." },
1191
+ expect(data.fields).toEqual(expect.arrayContaining(['MyCompany.QaPhase']));
1192
+ expect(data.fields).not.toEqual(
1193
+ expect.arrayContaining(['Elisra.TestPhase', 'Custom.TestPhase']),
1194
+ );
1195
+ return {
1196
+ value: [
1197
+ {
1198
+ id: 501,
1199
+ rev: 1,
1200
+ fields: {
1201
+ 'System.WorkItemType': 'Test Case',
1202
+ 'System.Title': 'Resolved',
1203
+ 'System.State': 'Active',
1204
+ 'System.ChangedDate': asOfIso,
1205
+ 'MyCompany.QaPhase': 'FAT',
1206
+ },
1192
1207
  },
1193
- };
1208
+ ],
1209
+ };
1210
+ }
1211
+ throw new Error(`unexpected URL: ${url}`);
1212
+ },
1213
+ );
1214
+
1215
+ const result = await provider.GetHistoricalQueryResults('q-resolve-field', project, asOfIso);
1216
+
1217
+ expect(result.total).toBe(1);
1218
+ const fieldsListCalls = (TFSServices.getItemContent as jest.Mock).mock.calls.filter((call) =>
1219
+ String(call[0]).endsWith('/_apis/wit/fields?api-version=7.1'),
1220
+ );
1221
+ expect(fieldsListCalls).toHaveLength(1);
1222
+ });
1223
+
1224
+ it('GetHistoricalQueryResults reuses the resolved field across baseline and compareTo without re-querying fields', async () => {
1225
+ const baselineIso = '2026-02-20T00:00:00.000Z';
1226
+ const compareIso = '2026-02-25T00:00:00.000Z';
1227
+ let fieldsListCallCount = 0;
1228
+
1229
+ (TFSServices.getItemContent as jest.Mock).mockImplementation(
1230
+ async (url: string, _pat: string, method?: string, data?: any) => {
1231
+ if (url.includes('/_apis/wit/queries/q-cache-field') && url.includes('api-version=7.1')) {
1232
+ return { name: 'Cache Field Q', wiql: 'SELECT [System.Id] FROM WorkItems' };
1233
+ }
1234
+ if (url.includes('/_apis/wit/wiql?') && method === 'post') {
1235
+ return { workItems: [{ id: 601 }] };
1236
+ }
1237
+ if (url.endsWith('/_apis/wit/fields?api-version=7.1')) {
1238
+ fieldsListCallCount += 1;
1239
+ return { value: [{ name: 'Test Phase', referenceName: 'Custom.TestPhase' }] };
1240
+ }
1241
+ if (url.includes('/_apis/wit/workitemsbatch') && method === 'post') {
1242
+ if (data?.$expand === 'Relations') {
1243
+ return { value: [{ id: 601, relations: [] }] };
1194
1244
  }
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
1245
  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',
1246
+ value: [
1247
+ {
1248
+ id: 601,
1249
+ rev: 1,
1250
+ fields: {
1251
+ 'System.WorkItemType': 'Test Case',
1252
+ 'System.Title': 'Cached',
1253
+ 'System.State': 'Active',
1254
+ 'System.ChangedDate': data.asOf,
1255
+ },
1210
1256
  },
1211
- })),
1257
+ ],
1212
1258
  };
1213
1259
  }
1214
1260
  throw new Error(`unexpected URL: ${url}`);
1215
1261
  },
1216
1262
  );
1217
1263
 
1218
- const result = await provider.GetHistoricalQueryResults('q-unknown-field', project, asOfIso);
1264
+ await provider.CompareHistoricalQueryResults('q-cache-field', project, baselineIso, compareIso);
1219
1265
 
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'),
1266
+ // Baseline and compareTo run concurrently against the same provider
1267
+ // instance — the field list is fetched once, not once per snapshot.
1268
+ expect(fieldsListCallCount).toBe(1);
1269
+ });
1270
+
1271
+ it('GetHistoricalQueryResults omits Test Phase entirely when the project defines neither known name, without failing the batch', async () => {
1272
+ const asOfIso = '2026-03-01T00:00:00.000Z';
1273
+
1274
+ (TFSServices.getItemContent as jest.Mock).mockImplementation(
1275
+ async (url: string, _pat: string, method?: string, data?: any) => {
1276
+ if (url.includes('/_apis/wit/queries/q-no-field') && url.includes('api-version=7.1')) {
1277
+ return { name: 'No Field Q', wiql: 'SELECT [System.Id] FROM WorkItems' };
1278
+ }
1279
+ if (url.includes('/_apis/wit/wiql?') && method === 'post') {
1280
+ return { workItems: [{ id: 701 }] };
1281
+ }
1282
+ if (url.endsWith('/_apis/wit/fields?api-version=7.1')) {
1283
+ return { value: [{ name: 'Title', referenceName: 'System.Title' }] };
1284
+ }
1285
+ if (url.includes('/_apis/wit/workitemsbatch') && method === 'post') {
1286
+ if (data?.$expand === 'Relations') {
1287
+ return { value: [{ id: 701, relations: [] }] };
1288
+ }
1289
+ expect(data.fields).not.toEqual(
1290
+ expect.arrayContaining(['Elisra.TestPhase', 'Custom.TestPhase']),
1291
+ );
1292
+ return {
1293
+ value: [
1294
+ {
1295
+ id: 701,
1296
+ rev: 1,
1297
+ fields: {
1298
+ 'System.WorkItemType': 'Bug',
1299
+ 'System.Title': 'No test phase field',
1300
+ 'System.State': 'Active',
1301
+ 'System.ChangedDate': asOfIso,
1302
+ },
1303
+ },
1304
+ ],
1305
+ };
1306
+ }
1307
+ throw new Error(`unexpected URL: ${url}`);
1308
+ },
1225
1309
  );
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);
1310
+
1311
+ const result = await provider.GetHistoricalQueryResults('q-no-field', project, asOfIso);
1312
+
1313
+ expect(result.total).toBe(1);
1229
1314
  expect(
1230
1315
  (logger.warn as jest.Mock).mock.calls.some((call) =>
1231
- String(call[0]).includes("field 'Elisra.TestPhase' not defined for this project"),
1316
+ String(call[0]).includes("no Test Phase field defined for project"),
1232
1317
  ),
1233
1318
  ).toBe(true);
1234
1319
  });