@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.
- package/.github/workflows/ci.yml +2 -0
- package/bin/helpers/tfs.js +21 -14
- package/bin/helpers/tfs.js.map +1 -1
- package/bin/modules/JfrogDataProvider.js +1 -1
- package/bin/modules/JfrogDataProvider.js.map +1 -1
- package/bin/modules/PipelinesDataProvider.js +1 -1
- package/bin/modules/PipelinesDataProvider.js.map +1 -1
- package/bin/modules/ResultDataProvider.js +21 -15
- package/bin/modules/ResultDataProvider.js.map +1 -1
- package/bin/modules/TicketsDataProvider.d.ts +3 -3
- package/bin/modules/TicketsDataProvider.js +80 -54
- package/bin/modules/TicketsDataProvider.js.map +1 -1
- package/bin/tests/modules/JfrogDataProvider.test.js +3 -1
- package/bin/tests/modules/JfrogDataProvider.test.js.map +1 -1
- package/bin/tests/modules/ResultDataProvider.test.js +6 -2
- package/bin/tests/modules/ResultDataProvider.test.js.map +1 -1
- package/bin/tests/modules/ticketsDataProvider.historical.test.js +111 -42
- package/bin/tests/modules/ticketsDataProvider.historical.test.js.map +1 -1
- package/bin/tests/utils/logger.test.d.ts +1 -0
- package/bin/tests/utils/logger.test.js +238 -0
- package/bin/tests/utils/logger.test.js.map +1 -0
- package/bin/tests/utils/runContext.test.d.ts +1 -0
- package/bin/tests/utils/runContext.test.js +51 -0
- package/bin/tests/utils/runContext.test.js.map +1 -0
- package/bin/utils/logSink.d.ts +25 -0
- package/bin/utils/logSink.js +16 -0
- package/bin/utils/logSink.js.map +1 -0
- package/bin/utils/logger.d.ts +6 -0
- package/bin/utils/logger.js +219 -27
- package/bin/utils/logger.js.map +1 -1
- package/bin/utils/runContext.d.ts +8 -0
- package/bin/utils/runContext.js +12 -0
- package/bin/utils/runContext.js.map +1 -0
- package/eslint.config.mjs +40 -0
- package/package.json +8 -3
- package/src/helpers/tfs.ts +19 -15
- package/src/modules/JfrogDataProvider.ts +1 -1
- package/src/modules/PipelinesDataProvider.ts +1 -1
- package/src/modules/ResultDataProvider.ts +20 -15
- package/src/modules/TicketsDataProvider.ts +93 -74
- package/src/tests/modules/JfrogDataProvider.test.ts +3 -1
- package/src/tests/modules/ResultDataProvider.test.ts +6 -2
- package/src/tests/modules/ticketsDataProvider.historical.test.ts +128 -43
- package/src/tests/utils/logger.test.ts +270 -0
- package/src/tests/utils/runContext.test.ts +48 -0
- package/src/utils/logSink.ts +44 -0
- package/src/utils/logger.ts +229 -29
- 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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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 ${
|
|
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}
|
|
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(
|
|
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(
|
|
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
|
-
//
|
|
102
|
-
//
|
|
103
|
-
|
|
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
|
|
785
|
-
|
|
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
|
-
//
|
|
2046
|
-
//
|
|
2047
|
-
//
|
|
2048
|
-
//
|
|
2049
|
-
//
|
|
2050
|
-
//
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
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
|
|
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
|
-
|
|
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]
|
|
2097
|
-
`
|
|
2119
|
+
`[historical-workitems] resolved Test Phase field was rejected by workitemsbatch for ` +
|
|
2120
|
+
`project '${project}'; retrying without it: ${this.historicalErrorMessage(error)}`,
|
|
2098
2121
|
);
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
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
|
-
|
|
2683
|
-
|
|
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
|
-
|
|
2694
|
-
|
|
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
|
-
|
|
2829
|
-
|
|
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
|
|
3147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6031
|
-
|
|
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
|
|
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-
|
|
1176
|
-
return { name: '
|
|
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:
|
|
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
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
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:
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
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
|
-
|
|
1264
|
+
await provider.CompareHistoricalQueryResults('q-cache-field', project, baselineIso, compareIso);
|
|
1219
1265
|
|
|
1220
|
-
//
|
|
1221
|
-
//
|
|
1222
|
-
expect(
|
|
1223
|
-
|
|
1224
|
-
|
|
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
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
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("
|
|
1316
|
+
String(call[0]).includes("no Test Phase field defined for project"),
|
|
1232
1317
|
),
|
|
1233
1318
|
).toBe(true);
|
|
1234
1319
|
});
|