@elisra-devops/docgen-data-provider 1.141.0 → 1.142.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 (63) hide show
  1. package/bin/helpers/requestContext.d.ts +51 -0
  2. package/bin/helpers/requestContext.js +218 -0
  3. package/bin/helpers/requestContext.js.map +1 -0
  4. package/bin/helpers/tfs.d.ts +5 -0
  5. package/bin/helpers/tfs.js +22 -29
  6. package/bin/helpers/tfs.js.map +1 -1
  7. package/bin/modules/GitDataProvider.js +3 -2
  8. package/bin/modules/GitDataProvider.js.map +1 -1
  9. package/bin/modules/JfrogDataProvider.js +2 -1
  10. package/bin/modules/JfrogDataProvider.js.map +1 -1
  11. package/bin/modules/PipelinesDataProvider.js +4 -3
  12. package/bin/modules/PipelinesDataProvider.js.map +1 -1
  13. package/bin/modules/ResultDataProvider.js +12 -11
  14. package/bin/modules/ResultDataProvider.js.map +1 -1
  15. package/bin/modules/TestDataProvider.js +4 -3
  16. package/bin/modules/TestDataProvider.js.map +1 -1
  17. package/bin/modules/TicketsDataProvider.js +13 -12
  18. package/bin/modules/TicketsDataProvider.js.map +1 -1
  19. package/bin/tests/helpers/requestContext.test.d.ts +1 -0
  20. package/bin/tests/helpers/requestContext.test.js +165 -0
  21. package/bin/tests/helpers/requestContext.test.js.map +1 -0
  22. package/bin/tests/helpers/tfs.test.js +54 -0
  23. package/bin/tests/helpers/tfs.test.js.map +1 -1
  24. package/bin/tests/modules/JfrogDataProvider.test.js +1 -1
  25. package/bin/tests/modules/JfrogDataProvider.test.js.map +1 -1
  26. package/bin/tests/modules/ResultDataProvider.test.js +4 -2
  27. package/bin/tests/modules/ResultDataProvider.test.js.map +1 -1
  28. package/bin/tests/modules/gitDataProvider.test.js +1 -1
  29. package/bin/tests/modules/gitDataProvider.test.js.map +1 -1
  30. package/bin/tests/modules/pipelineDataProvider.test.js +1 -1
  31. package/bin/tests/modules/pipelineDataProvider.test.js.map +1 -1
  32. package/bin/tests/modules/testDataProvider.test.js +1 -1
  33. package/bin/tests/modules/testDataProvider.test.js.map +1 -1
  34. package/bin/tests/modules/ticketsDataProvider.test.js +2 -2
  35. package/bin/tests/modules/ticketsDataProvider.test.js.map +1 -1
  36. package/bin/tests/utils/logger.test.js +94 -0
  37. package/bin/tests/utils/logger.test.js.map +1 -1
  38. package/bin/utils/logSink.d.ts +14 -0
  39. package/bin/utils/logSink.js +12 -0
  40. package/bin/utils/logSink.js.map +1 -1
  41. package/bin/utils/logger.d.ts +1 -0
  42. package/bin/utils/logger.js +48 -1
  43. package/bin/utils/logger.js.map +1 -1
  44. package/package.json +1 -1
  45. package/src/helpers/requestContext.ts +222 -0
  46. package/src/helpers/tfs.ts +28 -26
  47. package/src/modules/GitDataProvider.ts +3 -2
  48. package/src/modules/JfrogDataProvider.ts +2 -1
  49. package/src/modules/PipelinesDataProvider.ts +4 -3
  50. package/src/modules/ResultDataProvider.ts +12 -11
  51. package/src/modules/TestDataProvider.ts +4 -3
  52. package/src/modules/TicketsDataProvider.ts +13 -12
  53. package/src/tests/helpers/requestContext.test.ts +202 -0
  54. package/src/tests/helpers/tfs.test.ts +86 -0
  55. package/src/tests/modules/JfrogDataProvider.test.ts +2 -1
  56. package/src/tests/modules/ResultDataProvider.test.ts +5 -2
  57. package/src/tests/modules/gitDataProvider.test.ts +2 -1
  58. package/src/tests/modules/pipelineDataProvider.test.ts +2 -1
  59. package/src/tests/modules/testDataProvider.test.ts +4 -1
  60. package/src/tests/modules/ticketsDataProvider.test.ts +2 -2
  61. package/src/tests/utils/logger.test.ts +111 -0
  62. package/src/utils/logSink.ts +18 -3
  63. package/src/utils/logger.ts +43 -3
@@ -22,6 +22,7 @@ import type {
22
22
  MewpRunStatus,
23
23
  } from '../models/mewp-reporting';
24
24
  import logger from '../utils/logger';
25
+ import { describeError } from '../helpers/requestContext';
25
26
  import MewpExternalIngestionUtils from '../utils/mewpExternalIngestionUtils';
26
27
  import MewpExternalTableUtils, {
27
28
  MewpExternalFileValidationError,
@@ -139,7 +140,7 @@ export default class ResultDataProvider {
139
140
  this.fetchTestPoints(projectName, testPlanId, suite.testSuiteId)
140
141
  .then((testPointsItems) => ({ ...suite, testPointsItems }))
141
142
  .catch((error: any) => {
142
- logger.error(`Error occurred for suite ${suite.testSuiteId}: ${error.message}`);
143
+ logger.error(`Error occurred for suite ${suite.testSuiteId}:`, describeError(error));
143
144
  return { ...suite, testPointsItems: [] };
144
145
  })
145
146
  )
@@ -274,7 +275,7 @@ export default class ResultDataProvider {
274
275
  responseSummary = '[unserializable response data]';
275
276
  }
276
277
  }
277
- logger.error(`Error during getCombinedResultsSummary: ${error.message}`, { responseSummary });
278
+ logger.error('Error during getCombinedResultsSummary:', { ...describeError(error), responseSummary });
278
279
  // Ensure the error is rethrown to propagate it correctly
279
280
  throw new Error(error.message || 'Unknown error occurred during getCombinedResultsSummary');
280
281
  }
@@ -402,7 +403,7 @@ export default class ResultDataProvider {
402
403
 
403
404
  return fetchedTestResults;
404
405
  } catch (error: any) {
405
- logger.error(`Error during getTestReporterResults: ${error.message}`);
406
+ logger.error('Error during getTestReporterResults:', describeError(error));
406
407
  }
407
408
  }
408
409
 
@@ -443,7 +444,7 @@ export default class ResultDataProvider {
443
444
 
444
445
  return { planId: testPlanId, planName, rows: rows || [] };
445
446
  } catch (error: any) {
446
- logger.error(`Error during getTestReporterFlatResults: ${error.message}`);
447
+ logger.error('Error during getTestReporterFlatResults:', describeError(error));
447
448
  return { planId: testPlanId, planName: '', rows: [] };
448
449
  }
449
450
  }
@@ -745,7 +746,7 @@ export default class ResultDataProvider {
745
746
  rows,
746
747
  };
747
748
  } catch (error: any) {
748
- logger.error(`Error during getMewpL2CoverageFlatResults: ${error.message}`);
749
+ logger.error('Error during getMewpL2CoverageFlatResults:', describeError(error));
749
750
  if (error instanceof MewpExternalFileValidationError) {
750
751
  throw error;
751
752
  }
@@ -1122,7 +1123,7 @@ export default class ResultDataProvider {
1122
1123
  rows,
1123
1124
  };
1124
1125
  } catch (error: any) {
1125
- logger.error(`Error during getMewpInternalValidationFlatResults: ${error.message}`);
1126
+ logger.error('Error during getMewpInternalValidationFlatResults:', describeError(error));
1126
1127
  return defaultPayload;
1127
1128
  }
1128
1129
  }
@@ -3071,7 +3072,7 @@ export default class ResultDataProvider {
3071
3072
  const testPlan = await TFSServices.getItemContent(url, this.token);
3072
3073
  return testPlan.name;
3073
3074
  } catch (error: any) {
3074
- logger.error(`Error during fetching Test Plan Name: ${error.message}`);
3075
+ logger.error('Error during fetching Test Plan Name:', describeError(error));
3075
3076
  return '';
3076
3077
  }
3077
3078
  }
@@ -3119,7 +3120,7 @@ export default class ResultDataProvider {
3119
3120
  };
3120
3121
  });
3121
3122
  } catch (error: any) {
3122
- logger.error(`Error during fetching Test Suites: ${error.message}`);
3123
+ logger.error('Error during fetching Test Suites:', describeError(error));
3123
3124
  return [];
3124
3125
  }
3125
3126
  }
@@ -3292,7 +3293,7 @@ export default class ResultDataProvider {
3292
3293
 
3293
3294
  return count !== 0 ? testPoints.map((testPoint: any) => this.mapTestPoint(testPoint, projectName)) : [];
3294
3295
  } catch (error: any) {
3295
- logger.error(`Error during fetching Test Points: ${error.message}`);
3296
+ logger.error('Error during fetching Test Points:', describeError(error));
3296
3297
  return [];
3297
3298
  }
3298
3299
  }
@@ -4473,7 +4474,7 @@ export default class ResultDataProvider {
4473
4474
  relatedCRs.push({ id, title, workItemType: 'Change Request', url });
4474
4475
  }
4475
4476
  } catch (err: any) {
4476
- logger.error(`Could not append related work item to test case ${wiByRevision.id}: ${err.message}`);
4477
+ logger.error(`Could not append related work item to test case ${wiByRevision.id}:`, describeError(err));
4477
4478
  }
4478
4479
  }
4479
4480
  }
@@ -4856,7 +4857,7 @@ export default class ResultDataProvider {
4856
4857
 
4857
4858
  return { ...suite, testPointsItems, testCasesItems };
4858
4859
  } catch (error: any) {
4859
- logger.error(`Error occurred for suite ${suite.testSuiteId}: ${error.message}`);
4860
+ logger.error(`Error occurred for suite ${suite.testSuiteId}:`, describeError(error));
4860
4861
  return suite;
4861
4862
  }
4862
4863
  })
@@ -4,6 +4,7 @@ import { TestSteps, createMomRelation, createRequirementRelation } from '../mode
4
4
  import { TestCase } from '../models/tfs-data';
5
5
  import * as xml2js from 'xml2js';
6
6
  import logger from '../utils/logger';
7
+ import { describeError } from '../helpers/requestContext';
7
8
  import Utils from '../utils/testStepParserHelper';
8
9
  import DataProviderUtils from '../utils/DataProviderUtils';
9
10
  const pLimit = require('p-limit');
@@ -56,7 +57,7 @@ export default class TestDataProvider {
56
57
 
57
58
  return result;
58
59
  } catch (error: any) {
59
- logger.error(`Error fetching ${url}: ${error.message}`);
60
+ logger.error(`Error fetching ${url}:`, describeError(error));
60
61
  throw error;
61
62
  }
62
63
  }
@@ -187,7 +188,7 @@ export default class TestDataProvider {
187
188
  try {
188
189
  return await this.fetchWithCache(testsuitesUrl);
189
190
  } catch (e: any) {
190
- logger.error(`Failed to get test suites: ${e.message}`);
191
+ logger.error('Failed to get test suites:', describeError(e));
191
192
  return null;
192
193
  }
193
194
  }
@@ -599,7 +600,7 @@ export default class TestDataProvider {
599
600
  }
600
601
  }
601
602
  } catch (err: any) {
602
- logger.error(`Error: ${err.message} while trying to structure testCases for test suite ${suite.id}`);
603
+ logger.error(`Error while trying to structure testCases for test suite ${suite.id}:`, describeError(err));
603
604
  }
604
605
 
605
606
  return testCasesUrlList;
@@ -10,6 +10,7 @@ import { TestSteps } from '../models/tfs-data';
10
10
  import TestStepParserHelper from '../utils/testStepParserHelper';
11
11
 
12
12
  import logger from '../utils/logger';
13
+ import { describeError } from '../helpers/requestContext';
13
14
  const pLimit = require('p-limit');
14
15
 
15
16
  type FallbackFetchOutcome = {
@@ -491,7 +492,7 @@ export default class TicketsDataProvider {
491
492
  break;
492
493
  }
493
494
  } catch (err: any) {
494
- logger.error(`Error occurred during fetching shared queries: ${err.message}`);
495
+ logger.error('Error occurred during fetching shared queries:', describeError(err));
495
496
  throw err;
496
497
  }
497
498
  }
@@ -527,7 +528,7 @@ export default class TicketsDataProvider {
527
528
  })
528
529
  );
529
530
  } catch (err: any) {
530
- logger.error(`Error occurred during fetching fields by type: ${err.message}`);
531
+ logger.error('Error occurred during fetching fields by type:', describeError(err));
531
532
  throw err;
532
533
  }
533
534
  }
@@ -558,7 +559,7 @@ export default class TicketsDataProvider {
558
559
  }
559
560
  return { types, states: Array.from(stateSet) };
560
561
  } catch (err: any) {
561
- logger.error(`Error fetching work item type states: ${err.message}`);
562
+ logger.error('Error fetching work item type states:', describeError(err));
562
563
  throw err;
563
564
  }
564
565
  }
@@ -1286,7 +1287,7 @@ export default class TicketsDataProvider {
1286
1287
  break;
1287
1288
  }
1288
1289
  } catch (err: any) {
1289
- logger.error(`Could not fetch query results for ${wiqlHref}: ${err.message}`);
1290
+ logger.error(`Could not fetch query results for ${wiqlHref}:`, describeError(err));
1290
1291
  }
1291
1292
  }
1292
1293
 
@@ -1662,7 +1663,7 @@ export default class TicketsDataProvider {
1662
1663
  }
1663
1664
  return [...workItemsResultMap.values()];
1664
1665
  } catch (error: any) {
1665
- logger.error(`could not parse requested flat query: ${error.message}`);
1666
+ logger.error('could not parse requested flat query:', describeError(error));
1666
1667
  }
1667
1668
  }
1668
1669
 
@@ -3562,7 +3563,7 @@ export default class TicketsDataProvider {
3562
3563
 
3563
3564
  item.fields = { ...parsedFields };
3564
3565
  } catch (err: any) {
3565
- logger.error(`Cannot filter columns: ${err.message}`);
3566
+ logger.error('Cannot filter columns:', describeError(err));
3566
3567
  throw err;
3567
3568
  }
3568
3569
  }
@@ -3617,7 +3618,7 @@ export default class TicketsDataProvider {
3617
3618
 
3618
3619
  return workItemTypesWithIcons;
3619
3620
  } catch (err: any) {
3620
- logger.error(`Error occurred during fetching work item types: ${err.message}`);
3621
+ logger.error('Error occurred during fetching work item types:', describeError(err));
3621
3622
  throw err;
3622
3623
  }
3623
3624
  }
@@ -3770,7 +3771,7 @@ export default class TicketsDataProvider {
3770
3771
  categorizedRequirements['Precedence and Criticality of Requirements'].push(requirementItem);
3771
3772
  }
3772
3773
  } catch (err: any) {
3773
- logger.warn(`Could not fetch work item ${workItemId}: ${err.message}`);
3774
+ logger.warn(`Could not fetch work item ${workItemId}:`, describeError(err));
3774
3775
  }
3775
3776
  }
3776
3777
 
@@ -3792,7 +3793,7 @@ export default class TicketsDataProvider {
3792
3793
  totalCount: workItemIds.length,
3793
3794
  };
3794
3795
  } catch (err: any) {
3795
- logger.error(`Could not fetch categorized requirements: ${err.message}`);
3796
+ logger.error('Could not fetch categorized requirements:', describeError(err));
3796
3797
  throw err;
3797
3798
  }
3798
3799
  }
@@ -3887,7 +3888,7 @@ export default class TicketsDataProvider {
3887
3888
  const relations = qr?.workItemRelations || [];
3888
3889
  result['req-test'] = await resolveQuerySides(cols, relations, true);
3889
3890
  } catch (err: any) {
3890
- logger.error(`GetTraceColumnsByType[req-test] failed: ${err.message}`);
3891
+ logger.error('GetTraceColumnsByType[req-test] failed:', describeError(err));
3891
3892
  result['req-test'] = { Requirement: [], 'Test Case': [] };
3892
3893
  }
3893
3894
  }
@@ -3899,14 +3900,14 @@ export default class TicketsDataProvider {
3899
3900
  const relations = qr?.workItemRelations || [];
3900
3901
  result['test-req'] = await resolveQuerySides(cols, relations, false);
3901
3902
  } catch (err: any) {
3902
- logger.error(`GetTraceColumnsByType[test-req] failed: ${err.message}`);
3903
+ logger.error('GetTraceColumnsByType[test-req] failed:', describeError(err));
3903
3904
  result['test-req'] = { Requirement: [], 'Test Case': [] };
3904
3905
  }
3905
3906
  }
3906
3907
 
3907
3908
  return result;
3908
3909
  } catch (err: any) {
3909
- logger.error(`GetTraceColumnsByType failed: ${err.message}`);
3910
+ logger.error('GetTraceColumnsByType failed:', describeError(err));
3910
3911
  return {};
3911
3912
  }
3912
3913
  }
@@ -0,0 +1,202 @@
1
+ import {
2
+ sanitizeUrl,
3
+ summarizeBody,
4
+ summarizeResponse,
5
+ buildAdoRequestContext,
6
+ annotateAdoError,
7
+ maskWiqlLiterals,
8
+ } from '../../helpers/requestContext';
9
+
10
+ describe('maskWiqlLiterals', () => {
11
+ it('replaces every quoted literal with its length', () => {
12
+ expect(maskWiqlLiterals("a = 'xy' and b = ''")).toBe("a = '[2 chars]' and b = '[0 chars]'");
13
+ });
14
+ });
15
+
16
+ describe('sanitizeUrl', () => {
17
+ it('returns undefined for a missing or empty url', () => {
18
+ expect(sanitizeUrl(undefined)).toBeUndefined();
19
+ expect(sanitizeUrl('')).toBeUndefined();
20
+ });
21
+
22
+ it('keeps a normal ADO url intact', () => {
23
+ const url = 'https://dev.azure.com/org/proj/_apis/wit/queries/abc?$depth=1&api-version=7.0';
24
+ expect(sanitizeUrl(url)).toBe(url);
25
+ });
26
+
27
+ it('strips credentials embedded in the url', () => {
28
+ const out = sanitizeUrl('https://user:s3cret@dev.azure.com/org/_apis/projects');
29
+ expect(out).not.toContain('s3cret');
30
+ expect(out).not.toContain('user');
31
+ expect(out).toContain('dev.azure.com/org/_apis/projects');
32
+ });
33
+
34
+ it('masks sensitive query parameter values but keeps ordinary ones', () => {
35
+ const out = sanitizeUrl('https://host/api?access_token=abc123&top=10&sig=zzz&keywords=release')!;
36
+ expect(out).not.toContain('abc123');
37
+ expect(out).not.toContain('zzz');
38
+ expect(out).toContain('top=10');
39
+ expect(out).toContain('keywords=release');
40
+ });
41
+
42
+ it('leaves non-sensitive parameters byte-identical when masking another', () => {
43
+ expect(sanitizeUrl('https://h/api?$filter=a%20b&token=x&q=a+b')).toBe('https://h/api?$filter=a%20b&token=[REDACTED]&q=a+b');
44
+ });
45
+
46
+ it('passes a non-absolute url through, bounded', () => {
47
+ expect(sanitizeUrl('/relative/path')).toBe('/relative/path');
48
+ expect(sanitizeUrl('x'.repeat(5000))!.length).toBeLessThanOrEqual(1001);
49
+ });
50
+ });
51
+
52
+ describe('summarizeBody', () => {
53
+ it('never describes a GET body', () => {
54
+ expect(summarizeBody({ query: 'SELECT 1' }, 'get')).toBeUndefined();
55
+ expect(summarizeBody({ query: 'SELECT 1' }, 'HEAD')).toBeUndefined();
56
+ });
57
+
58
+ it('returns undefined for an empty body — the default `{}` callers pass', () => {
59
+ expect(summarizeBody({}, 'post')).toBeUndefined();
60
+ expect(summarizeBody([], 'post')).toBeUndefined();
61
+ expect(summarizeBody(null, 'post')).toBeUndefined();
62
+ expect(summarizeBody('', 'post')).toBeUndefined();
63
+ });
64
+
65
+ it('keeps a WIQL-style body so the failing query is visible', () => {
66
+ const out = summarizeBody({ query: "SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = 'P'" }, 'post')!;
67
+ expect(out).toContain('SELECT [System.Id] FROM WorkItems');
68
+ expect(out).toContain("[System.TeamProject] = '[1 chars]'");
69
+ expect(out).not.toContain("= 'P'");
70
+ });
71
+
72
+ it('masks WIQL literals (incl. escaped quotes) but keeps fields, numbers and macros', () => {
73
+ const out = summarizeBody({ query: "SELECT [Id] FROM WorkItems WHERE [Title] = 'it''s secret' AND [Assigned] = @Me AND [Id] > 42" }, 'post')!;
74
+ expect(out).not.toContain('secret');
75
+ expect(out).toContain("[Title] = '[12 chars]'");
76
+ expect(out).toContain('@Me');
77
+ expect(out).toContain('> 42');
78
+ });
79
+
80
+ it('keeps an id-list body', () => {
81
+ expect(summarizeBody({ ids: [1, 2, 3], fields: ['System.Title'] }, 'post')).toBe(
82
+ '{"ids":[1,2,3],"fields":["System.Title"]}'
83
+ );
84
+ });
85
+
86
+ it('redacts credential-looking keys at any depth', () => {
87
+ const out = summarizeBody({ query: 'q', auth: { token: 'tok-123', password: 'pw-456' }, pat: 'pat-789' }, 'post')!;
88
+ expect(out).not.toContain('tok-123');
89
+ expect(out).not.toContain('pw-456');
90
+ expect(out).not.toContain('pat-789');
91
+ expect(out).toContain('[REDACTED]');
92
+ expect(out).toContain('"query":"q"');
93
+ });
94
+
95
+ it('JSON-Patch bodies keep op + path only — field values are dropped', () => {
96
+ const patch = [
97
+ { op: 'add', path: '/fields/System.Title', value: 'Highly classified title' },
98
+ { op: 'replace', path: '/fields/System.Description', value: { html: '<p>secret</p>' } },
99
+ ];
100
+ const out = summarizeBody(patch, 'patch')!;
101
+ expect(out).toContain('json-patch (2 ops, values omitted)');
102
+ expect(out).toContain('add /fields/System.Title');
103
+ expect(out).toContain('replace /fields/System.Description');
104
+ expect(out).not.toContain('classified');
105
+ expect(out).not.toContain('secret');
106
+ });
107
+
108
+ it('bounds a long string value and the whole body', () => {
109
+ const one = summarizeBody({ query: 'x'.repeat(5000) }, 'post')!;
110
+ expect(one.length).toBeLessThan(700);
111
+ const many = summarizeBody(Object.fromEntries(Array.from({ length: 300 }, (_, i) => [`k${i}`, 'v'.repeat(40)])), 'post')!;
112
+ expect(many.length).toBeLessThanOrEqual(2001);
113
+ });
114
+
115
+ it('caps a very long array', () => {
116
+ const out = summarizeBody({ ids: Array.from({ length: 500 }, (_, i) => i) }, 'post')!;
117
+ expect(out).toContain('more');
118
+ });
119
+
120
+ it('describes non-JSON bodies by type and size only', () => {
121
+ expect(summarizeBody(Buffer.from('abcdef'), 'post')).toBe('[binary, 6 bytes]');
122
+ expect(summarizeBody('not json at all', 'post')).toBe('[text, 15 chars]');
123
+ expect(summarizeBody(42, 'post')).toBe('[number]');
124
+ expect(summarizeBody({ pipe: () => undefined }, 'post')).toBe('[stream]');
125
+ });
126
+
127
+ it('parses a JSON string body and summarizes it like an object', () => {
128
+ expect(summarizeBody('{"query":"q"}', 'post')).toBe('{"query":"q"}');
129
+ });
130
+
131
+ it('never throws on a body it cannot serialize', () => {
132
+ expect(summarizeBody({ n: BigInt(1) }, 'post')).toBe('[unserializable body]');
133
+ const circular: Record<string, unknown> = { a: 1 };
134
+ circular.self = circular;
135
+ expect(() => summarizeBody(circular, 'post')).not.toThrow();
136
+ });
137
+ });
138
+
139
+ describe('summarizeResponse', () => {
140
+ it('prefers the server message (ADO puts its explanation there)', () => {
141
+ expect(summarizeResponse({ message: 'TF401232: Work item 5 does not exist', typeName: 'x' })).toBe(
142
+ 'TF401232: Work item 5 does not exist'
143
+ );
144
+ });
145
+
146
+ it('truncates a text response (e.g. an HTML error page) to 200 chars', () => {
147
+ expect(summarizeResponse('<html>'.repeat(200))!.length).toBeLessThanOrEqual(201);
148
+ });
149
+
150
+ it('falls back to a redacted, bounded JSON dump when there is no message', () => {
151
+ const out = summarizeResponse({ code: 1, token: 'leak-me' })!;
152
+ expect(out).not.toContain('leak-me');
153
+ expect(out).toContain('"code":1');
154
+ });
155
+
156
+ it('does not walk a stream response', () => {
157
+ expect(summarizeResponse({ pipe: () => undefined, socket: { a: 1 } })).toBe('[stream]');
158
+ });
159
+
160
+ it('ignores a missing or binary response', () => {
161
+ expect(summarizeResponse(undefined)).toBeUndefined();
162
+ expect(summarizeResponse(Buffer.from('x'))).toBeUndefined();
163
+ });
164
+ });
165
+
166
+ describe('buildAdoRequestContext', () => {
167
+ it('describes a failed GET with method, status, attempt and the server message', () => {
168
+ const err = { response: { status: 404, data: { message: 'not found' } } };
169
+ expect(buildAdoRequestContext(err, { method: 'get', url: 'https://h/_apis/x' }, 2)).toEqual({
170
+ method: 'GET',
171
+ url: 'https://h/_apis/x',
172
+ status: 404,
173
+ attempt: 2,
174
+ responseExcerpt: 'not found',
175
+ });
176
+ });
177
+
178
+ it('includes a summarized body for a POST and omits fields it does not have', () => {
179
+ const ctx = buildAdoRequestContext(new Error('boom'), { method: 'post', url: 'https://h/wiql', data: { query: 'q' } });
180
+ expect(ctx).toEqual({ method: 'POST', url: 'https://h/wiql', requestBody: '{"query":"q"}' });
181
+ });
182
+
183
+ it('falls back to the axios error config when the request is not described explicitly', () => {
184
+ const ctx = buildAdoRequestContext({ config: { method: 'put', url: 'https://h/y' } }, {});
185
+ expect(ctx.method).toBe('PUT');
186
+ expect(ctx.url).toBe('https://h/y');
187
+ });
188
+ });
189
+
190
+ describe('annotateAdoError', () => {
191
+ it('attaches the context as an enumerable property winston will merge onto the record', () => {
192
+ const err = annotateAdoError(new Error('x'), { method: 'GET', url: 'https://h/z' });
193
+ expect(Object.keys(err)).toContain('adoRequest');
194
+ expect((err as any).adoRequest.url).toBe('https://h/z');
195
+ });
196
+
197
+ it('never throws on a frozen or non-object error', () => {
198
+ expect(() => annotateAdoError(Object.freeze(new Error('x')), { url: 'u' })).not.toThrow();
199
+ expect(() => annotateAdoError('a string' as any, { url: 'u' })).not.toThrow();
200
+ expect(() => annotateAdoError(undefined as any, { url: 'u' })).not.toThrow();
201
+ });
202
+ });
@@ -1089,3 +1089,89 @@ describe('TFSServices', () => {
1089
1089
  });
1090
1090
  });
1091
1091
  });
1092
+
1093
+ describe('TFSServices request-context annotation', () => {
1094
+ // Math.random is pinned and setTimeout collapsed so a retried failure doesn't slow the suite.
1095
+ beforeEach(() => {
1096
+ jest.clearAllMocks();
1097
+ mockAxiosInstance.request.mockReset();
1098
+ });
1099
+
1100
+ const notFound = () => {
1101
+ const e: any = new Error('Request failed with status code 404');
1102
+ e.response = { status: 404, data: { message: 'TF401232: Work item 5 does not exist' } };
1103
+ return e;
1104
+ };
1105
+
1106
+ it('annotates the thrown error with the request even when printError is false, without logging', async () => {
1107
+ mockAxiosInstance.request.mockRejectedValue(notFound());
1108
+
1109
+ const caught = await TFSServices.getItemContent('https://dev.azure.com/org/_apis/wit/queries/q1', 'pat', 'get', {}, {}, false).catch(
1110
+ (e) => e
1111
+ );
1112
+
1113
+ expect(caught.adoRequest).toEqual({
1114
+ method: 'GET',
1115
+ url: 'https://dev.azure.com/org/_apis/wit/queries/q1',
1116
+ status: 404,
1117
+ attempt: 1,
1118
+ responseExcerpt: 'TF401232: Work item 5 does not exist',
1119
+ });
1120
+ expect(logger.error).not.toHaveBeenCalled();
1121
+ });
1122
+
1123
+ it('with printError, also emits the one "ADO request failed" record carrying the same context', async () => {
1124
+ mockAxiosInstance.request.mockRejectedValue(notFound());
1125
+
1126
+ await TFSServices.getItemContent('https://dev.azure.com/org/_apis/x', 'pat').catch(() => undefined);
1127
+
1128
+ expect(logger.error).toHaveBeenCalledTimes(1);
1129
+ expect(logger.error).toHaveBeenCalledWith(
1130
+ 'ADO request failed',
1131
+ expect.objectContaining({
1132
+ adoRequest: expect.objectContaining({ method: 'GET', url: 'https://dev.azure.com/org/_apis/x', status: 404 }),
1133
+ })
1134
+ );
1135
+ });
1136
+
1137
+ it('never leaks the PAT: credentials are applied to the axios config, not the description', async () => {
1138
+ mockAxiosInstance.request.mockRejectedValue(notFound());
1139
+
1140
+ const caught = await TFSServices.getItemContent('https://dev.azure.com/org/_apis/x', 'super-secret-pat', 'get', {}, {}, false).catch(
1141
+ (e) => e
1142
+ );
1143
+
1144
+ expect(JSON.stringify(caught.adoRequest)).not.toContain('super-secret-pat');
1145
+ });
1146
+
1147
+ it('postRequest describes a JSON-Patch body by op + path only', async () => {
1148
+ mockAxiosInstance.request.mockRejectedValue(notFound());
1149
+
1150
+ const caught = await TFSServices.postRequest(
1151
+ 'https://dev.azure.com/org/_apis/wit/workitems/9',
1152
+ 'pat',
1153
+ 'PATCH',
1154
+ [{ op: 'add', path: '/fields/System.Title', value: 'confidential title' }]
1155
+ ).catch((e) => e);
1156
+
1157
+ expect(caught.adoRequest.method).toBe('PATCH');
1158
+ expect(caught.adoRequest.requestBody).toContain('add /fields/System.Title');
1159
+ expect(caught.adoRequest.requestBody).not.toContain('confidential');
1160
+ });
1161
+
1162
+ it('getJfrogRequest annotates too', async () => {
1163
+ mockAxiosInstance.request.mockRejectedValue(notFound());
1164
+
1165
+ const caught = await TFSServices.getJfrogRequest('https://jfrog.example/api/x').catch((e) => e);
1166
+
1167
+ expect(caught.adoRequest).toEqual(expect.objectContaining({ method: 'GET', url: 'https://jfrog.example/api/x', status: 404 }));
1168
+ });
1169
+
1170
+ it('downloadZipFile annotates the error it rethrows', async () => {
1171
+ mockAxiosInstance.request.mockRejectedValue(notFound());
1172
+
1173
+ const caught = await TFSServices.downloadZipFile('https://dev.azure.com/org/file.zip', 'pat').catch((e) => e);
1174
+
1175
+ expect(caught.adoRequest).toEqual(expect.objectContaining({ method: 'GET', url: 'https://dev.azure.com/org/file.zip', status: 404 }));
1176
+ });
1177
+ });
@@ -162,7 +162,8 @@ describe('JfrogDataProvider', () => {
162
162
  ).rejects.toThrow('JFrog API error');
163
163
 
164
164
  expect(logger.error).toHaveBeenCalledWith(
165
- `Error occurred during querying JFrog using: JFrog API error`
165
+ 'Error occurred during querying JFrog using:',
166
+ expect.objectContaining({ message: 'JFrog API error' })
166
167
  );
167
168
  });
168
169
  });
@@ -5324,7 +5324,8 @@ describe('ResultDataProvider', () => {
5324
5324
  );
5325
5325
 
5326
5326
  expect(logger.error).toHaveBeenCalledWith(
5327
- expect.stringContaining('Could not append related work item to test case 999')
5327
+ 'Could not append related work item to test case 999:',
5328
+ expect.objectContaining({ message: 'network' })
5328
5329
  );
5329
5330
  });
5330
5331
  });
@@ -6029,7 +6030,9 @@ describe('ResultDataProvider', () => {
6029
6030
 
6030
6031
  // Was two separate calls (message, then the response body) — consolidated into one
6031
6032
  // record with the response summary as meta (docgen-log-redaction / Phase 2/4).
6032
- expect(logger.error).toHaveBeenCalledWith('Error during getCombinedResultsSummary: boom', {
6033
+ expect(logger.error).toHaveBeenCalledWith('Error during getCombinedResultsSummary:', {
6034
+ message: 'boom',
6035
+ stack: expect.any(String),
6033
6036
  responseSummary: '{"detail":"bad"}',
6034
6037
  });
6035
6038
  expect(logger.error).toHaveBeenCalledTimes(1);
@@ -367,7 +367,8 @@ describe('GitDataProvider - GetFileFromGitRepo', () => {
367
367
 
368
368
  // Assert
369
369
  expect(logger.warn).toHaveBeenCalledWith(
370
- expect.stringContaining(`File ${mockFileName} could not be read: ${mockError.message}`)
370
+ `File ${mockFileName} could not be read:`,
371
+ expect.objectContaining({ message: mockError.message })
371
372
  );
372
373
  expect(result).toBeUndefined();
373
374
  });
@@ -305,7 +305,8 @@ describe('PipelinesDataProvider', () => {
305
305
  null
306
306
  );
307
307
  expect(logger.error).toHaveBeenCalledWith(
308
- `Could not fetch Pipeline Run History: ${expectedError.message}`
308
+ 'Could not fetch Pipeline Run History:',
309
+ expect.objectContaining({ message: expectedError.message })
309
310
  );
310
311
  expect(result).toBeUndefined();
311
312
  });
@@ -105,7 +105,10 @@ describe('TestDataProvider', () => {
105
105
 
106
106
  // Act & Assert
107
107
  await expect(invokeFetchWithCache(testDataProvider, mockUrl)).rejects.toThrow('API call failed');
108
- expect(logger.error).toHaveBeenCalledWith(`Error fetching ${mockUrl}: API call failed`);
108
+ expect(logger.error).toHaveBeenCalledWith(
109
+ `Error fetching ${mockUrl}:`,
110
+ expect.objectContaining({ message: 'API call failed' })
111
+ );
109
112
  });
110
113
  });
111
114
 
@@ -1108,7 +1108,7 @@ describe('TicketsDataProvider', () => {
1108
1108
  expect(() =>
1109
1109
  (ticketsDataProvider as any).filterFieldsByColumns(item, columnsToFilterMap, resultedRefNameMap),
1110
1110
  ).toThrow();
1111
- expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('Cannot filter columns'));
1111
+ expect(logger.error).toHaveBeenCalledWith('Cannot filter columns:', expect.objectContaining({ message: expect.any(String) }));
1112
1112
  });
1113
1113
  });
1114
1114
 
@@ -2779,7 +2779,7 @@ describe('TicketsDataProvider', () => {
2779
2779
  expect(res.categories['Security and Privacy Requirements']).toHaveLength(1);
2780
2780
  expect(res.categories['Precedence and Criticality of Requirements']).toHaveLength(1);
2781
2781
  expect(res.categories['Other Requirements']).toHaveLength(1);
2782
- expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Could not fetch work item 13'));
2782
+ expect(logger.warn).toHaveBeenCalledWith('Could not fetch work item 13:', expect.objectContaining({ message: 'boom' }));
2783
2783
  });
2784
2784
 
2785
2785
  it('should extract IDs from workItemRelations for OneHop queries', async () => {