Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/prompt/node/test/feedbackGenerator.spec.ts
13405 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { Raw } from '@vscode/prompt-tsx';
7
import assert from 'assert';
8
import { afterEach, beforeEach, describe, suite, test } from 'vitest';
9
import { ChatFetchResponseType, ChatResponse } from '../../../../platform/chat/common/commonTypes';
10
import { TextDocumentSnapshot } from '../../../../platform/editing/common/textDocumentSnapshot';
11
import { IEndpointProvider } from '../../../../platform/endpoint/common/endpointProvider';
12
import { IIgnoreService, NullIgnoreService } from '../../../../platform/ignore/common/ignoreService';
13
import { IChatEndpoint } from '../../../../platform/networking/common/networking';
14
import { ReviewComment, ReviewRequest } from '../../../../platform/review/common/reviewService';
15
import { NullTelemetryService } from '../../../../platform/telemetry/common/nullTelemetryService';
16
import { ITelemetryService, TelemetryEventMeasurements, TelemetryEventProperties } from '../../../../platform/telemetry/common/telemetry';
17
import { TestLogService } from '../../../../platform/testing/common/testLogService';
18
import { createTextDocumentData } from '../../../../util/common/test/shims/textDocument';
19
import { CancellationToken, CancellationTokenSource } from '../../../../util/vs/base/common/cancellation';
20
import { Event } from '../../../../util/vs/base/common/event';
21
import { DisposableStore } from '../../../../util/vs/base/common/lifecycle';
22
import * as path from '../../../../util/vs/base/common/path';
23
import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation';
24
import { MarkdownString, Range, Uri } from '../../../../vscodeTypes';
25
import { CurrentChangeInput } from '../../../prompts/node/feedback/currentChange';
26
import { createExtensionUnitTestingServices } from '../../../test/node/services';
27
import { FeedbackGenerator, parseFeedbackResponse, parseReviewComments, sendReviewActionTelemetry } from '../feedbackGenerator';
28
29
suite('feedbackGenerator', () => {
30
31
function createTestSnapshot(uri: Uri, content: string, languageId = 'typescript'): TextDocumentSnapshot {
32
const docData = createTextDocumentData(uri, content, languageId);
33
return TextDocumentSnapshot.create(docData.document);
34
}
35
36
function createReviewRequest(overrides?: Partial<ReviewRequest>): ReviewRequest {
37
return {
38
source: 'vscodeCopilotChat',
39
promptCount: 1,
40
messageId: 'test-message-id',
41
inputType: 'change',
42
inputRanges: [],
43
...overrides,
44
};
45
}
46
47
describe('parseFeedbackResponse', () => {
48
49
// Legacy test case before refactor
50
test('Correctly parses reply', function () {
51
const fileContents = `1. Line 33 in \`requestLoggerImpl.ts\`, readability, low severity: The lambda function used in \`onDidChange\` could be extracted into a named function for better readability and reusability.
52
\`\`\`typescript
53
this._register(workspace.registerTextDocumentContentProvider(ChatRequestScheme.chatRequestScheme, {
54
onDidChange: Event.map(this.onDidChangeRequests, this._mapToLatestUri),
55
provideTextDocumentContent: (uri) => {
56
const uriData = ChatRequestScheme.parseUri(uri.toString());
57
if (!uriData) { return \`Invalid URI: \${uri}\`; }
58
59
const entry = uriData.kind === 'latest' ? this._entries[this._entries.length - 1] : this._entries.find(e => e.id === uriData.id);
60
if (!entry) { return \`Request not found\`; }
61
62
if (entry.kind === LoggedInfoKind.Element) { return entry.html; }
63
64
return this._renderEntryToMarkdown(entry.id, entry.entry);
65
}
66
}));
67
68
private _mapToLatestUri = () => Uri.parse(ChatRequestScheme.buildUri({ kind: 'latest' }));
69
\`\`\``;
70
const matches = parseFeedbackResponse(fileContents);
71
assert.strictEqual(matches.length, 1);
72
assert.strictEqual(matches[0].from, 32);
73
assert.strictEqual(matches[0].content.indexOf('```'), -1);
74
});
75
76
test('parses single comment with all fields including linkOffset and linkLength', () => {
77
const response = '1. Line 10 in `file.ts`, bug, high severity: This is a bug.';
78
const matches = parseFeedbackResponse(response);
79
80
assert.strictEqual(matches.length, 1);
81
assert.strictEqual(matches[0].from, 9); // 0-indexed
82
assert.strictEqual(matches[0].to, 10);
83
assert.strictEqual(matches[0].relativeDocumentPath, 'file.ts');
84
assert.strictEqual(matches[0].kind, 'bug');
85
assert.strictEqual(matches[0].severity, 'high');
86
assert.strictEqual(matches[0].content, 'This is a bug.');
87
// linkOffset = match.index + num.length + 2 = 0 + 1 + 2 = 3
88
assert.strictEqual(matches[0].linkOffset, 3);
89
// linkLength = 5 ("Line ") + from.length (2 for "10") = 7
90
assert.strictEqual(matches[0].linkLength, 7);
91
});
92
93
test('parses comment without backticks around path', () => {
94
const response = '1. Line 5 in file.ts, performance, medium severity: Slow code.';
95
const matches = parseFeedbackResponse(response);
96
97
assert.strictEqual(matches.length, 1);
98
assert.strictEqual(matches[0].relativeDocumentPath, 'file.ts');
99
});
100
101
test('parses line range (from-to) with correct linkLength', () => {
102
const response = '1. Line 10-15 in `file.ts`, bug, high severity: Multiple lines affected.';
103
const matches = parseFeedbackResponse(response);
104
105
assert.strictEqual(matches.length, 1);
106
assert.strictEqual(matches[0].from, 9); // 0-indexed
107
assert.strictEqual(matches[0].to, 15);
108
// linkLength = 5 ("Line ") + from.length (2) + to.length (2) + 1 ("-") = 10
109
assert.strictEqual(matches[0].linkLength, 5 + 2 + 2 + 1);
110
});
111
112
test('defaults kind to "other" and severity to "unknown" when not specified', () => {
113
const response = '1. Line 42 in `utils.js`: Minimal comment.';
114
const matches = parseFeedbackResponse(response);
115
116
assert.strictEqual(matches.length, 1);
117
assert.strictEqual(matches[0].kind, 'other');
118
assert.strictEqual(matches[0].severity, 'unknown');
119
});
120
121
test('parses comment with extra text before "in" keyword', () => {
122
const response = '1. Line 10 (modified) in `file.ts`, bug: Issue with extra text.';
123
const matches = parseFeedbackResponse(response);
124
125
assert.strictEqual(matches.length, 1);
126
assert.strictEqual(matches[0].from, 9);
127
assert.strictEqual(matches[0].relativeDocumentPath, 'file.ts');
128
});
129
130
test('parses multiple comments separated by newlines or next numbered item', () => {
131
const response = `1. Line 10 in \`file.ts\`, bug, high severity: First issue.
132
133
2. Line 20 in \`other.ts\`, performance, low severity: Second issue.
134
3. Line 30 in \`third.ts\`, bug: Third issue.`;
135
const matches = parseFeedbackResponse(response);
136
137
assert.strictEqual(matches.length, 3);
138
assert.strictEqual(matches[0].content, 'First issue.');
139
assert.strictEqual(matches[1].content, 'Second issue.');
140
assert.strictEqual(matches[2].content, 'Third issue.');
141
});
142
143
test('keeps partial comment when dropPartial is false, drops when true', () => {
144
const partialResponse = '1. Line 10 in `file.ts`, bug: Incomplete';
145
146
// dropPartial = false (default) keeps partial
147
const matchesKept = parseFeedbackResponse(partialResponse, false);
148
assert.strictEqual(matchesKept.length, 1);
149
assert.strictEqual(matchesKept[0].content, 'Incomplete');
150
151
// dropPartial = true drops partial
152
const matchesDropped = parseFeedbackResponse(partialResponse, true);
153
assert.strictEqual(matchesDropped.length, 0);
154
});
155
156
test('keeps first complete comment but drops partial second when dropPartial is true', () => {
157
const response = `1. Line 10 in \`file.ts\`, bug: First complete.
158
159
2. Line 20 in \`other.ts\`, bug: Partial`;
160
const matches = parseFeedbackResponse(response, true);
161
162
assert.strictEqual(matches.length, 1);
163
assert.strictEqual(matches[0].content, 'First complete.');
164
});
165
166
test('removes trailing complete code block', () => {
167
const response = `1. Line 33 in \`file.ts\`, readability, low severity: The lambda function could be extracted.
168
\`\`\`typescript
169
const extracted = () => doSomething();
170
\`\`\``;
171
const matches = parseFeedbackResponse(response);
172
173
assert.strictEqual(matches.length, 1);
174
assert.strictEqual(matches[0].content, 'The lambda function could be extracted.');
175
assert.strictEqual(matches[0].content.indexOf('```'), -1);
176
});
177
178
test('removes broken code block (odd number of markers)', () => {
179
const response = '1. Line 10 in `file.ts`, bug: Here is some code:\n```typescript\nconst x = 1;';
180
const matches = parseFeedbackResponse(response);
181
182
assert.strictEqual(matches.length, 1);
183
assert.strictEqual(matches[0].content, 'Here is some code:');
184
});
185
186
test('preserves inline code (single backticks)', () => {
187
const response = '1. Line 10 in `file.ts`, bug: The variable `foo` should be renamed.';
188
const matches = parseFeedbackResponse(response);
189
190
assert.strictEqual(matches.length, 1);
191
assert.strictEqual(matches[0].content, 'The variable `foo` should be renamed.');
192
});
193
194
test('removes trailing ``` via broken block handler when no opening marker exists', () => {
195
const response = '1. Line 10 in `file.ts`, bug: Some text ending with ```\n\n';
196
const matches = parseFeedbackResponse(response);
197
198
assert.strictEqual(matches.length, 1);
199
// Since there's no matching opening ```, the trailing ``` removal fails (i === -1),
200
// but the broken block handler (odd count) removes it
201
assert.strictEqual(matches[0].content, 'Some text ending with');
202
});
203
204
test('preserves complete code block in middle but removes trailing code block', () => {
205
const response = `1. Line 10 in \`file.ts\`, bug: Example:
206
\`\`\`typescript
207
const x = 1;
208
\`\`\`
209
Fix:
210
\`\`\`typescript
211
const y = 2;
212
\`\`\``;
213
const matches = parseFeedbackResponse(response);
214
215
assert.strictEqual(matches.length, 1);
216
// Should keep the first code block but remove the trailing one
217
assert.ok(matches[0].content.includes('Example:'));
218
assert.ok(matches[0].content.includes('```typescript'));
219
assert.ok(matches[0].content.includes('const x = 1;'));
220
assert.strictEqual(matches[0].content.includes('const y = 2;'), false);
221
});
222
223
test('normalizes path separators for subdirectories', () => {
224
const response = '1. Line 10 in `src/utils/helpers.ts`, bug: Issue.\n\n';
225
const matches = parseFeedbackResponse(response);
226
227
assert.strictEqual(matches.length, 1);
228
// On Windows, forward slashes should be converted to backslashes
229
// On Unix, paths stay with forward slashes
230
if (path.sep === '\\') {
231
assert.strictEqual(matches[0].relativeDocumentPath, 'src\\utils\\helpers.ts');
232
} else {
233
assert.strictEqual(matches[0].relativeDocumentPath, 'src/utils/helpers.ts');
234
}
235
});
236
237
test('returns empty array for empty or invalid response', () => {
238
assert.strictEqual(parseFeedbackResponse('').length, 0);
239
assert.strictEqual(parseFeedbackResponse('This is just some text without the expected format.').length, 0);
240
});
241
242
test('handles line number 1 correctly (0-indexed)', () => {
243
const response = '1. Line 1 in `file.ts`, bug: First line issue.';
244
const matches = parseFeedbackResponse(response);
245
246
assert.strictEqual(matches.length, 1);
247
assert.strictEqual(matches[0].from, 0);
248
assert.strictEqual(matches[0].to, 1);
249
});
250
251
test('trims whitespace from content', () => {
252
const response = '1. Line 10 in `file.ts`, bug: Spaces around content. ';
253
const matches = parseFeedbackResponse(response);
254
255
assert.strictEqual(matches.length, 1);
256
assert.strictEqual(matches[0].content, 'Spaces around content.');
257
});
258
259
test('handles multiline content before next comment', () => {
260
const response = `1. Line 10 in \`file.ts\`, bug: This is a longer
261
description that spans
262
multiple lines.
263
264
2. Line 20 in \`other.ts\`, bug: Next issue.`;
265
const matches = parseFeedbackResponse(response);
266
267
assert.strictEqual(matches.length, 2);
268
assert.ok(matches[0].content.includes('longer'));
269
assert.ok(matches[0].content.includes('multiple lines.'));
270
});
271
272
test('handles multi-digit item numbers for linkOffset calculation', () => {
273
const response = '10. Line 5 in `file.ts`, bug: Issue.\n\n';
274
const matches = parseFeedbackResponse(response);
275
276
assert.strictEqual(matches.length, 1);
277
// linkOffset = match.index + num.length + 2 = 0 + 2 + 2 = 4
278
assert.strictEqual(matches[0].linkOffset, 4);
279
});
280
});
281
282
describe('parseReviewComments', () => {
283
284
test('parses valid comment and creates ReviewComment', () => {
285
const uri = Uri.file('/test/file.ts');
286
const content = 'line 0\nline 1\nline 2\nline 3\nline 4';
287
const snapshot = createTestSnapshot(uri, content);
288
const input: CurrentChangeInput[] = [{
289
document: snapshot,
290
relativeDocumentPath: 'file.ts',
291
change: {
292
repository: {} as any,
293
uri,
294
hunks: [{ range: new Range(0, 0, 4, 6), text: content }]
295
}
296
}];
297
const request = createReviewRequest();
298
const message = '1. Line 2 in `file.ts`, bug, high severity: This is a bug.\n\n';
299
300
const comments = parseReviewComments(request, input, message);
301
302
assert.strictEqual(comments.length, 1);
303
assert.strictEqual(comments[0].kind, 'bug');
304
assert.strictEqual(comments[0].severity, 'high');
305
assert.strictEqual(typeof comments[0].body === 'string' ? comments[0].body : comments[0].body.value, 'This is a bug.');
306
assert.strictEqual(comments[0].uri, uri);
307
assert.strictEqual(comments[0].languageId, 'typescript');
308
assert.strictEqual(comments[0].originalIndex, 0);
309
assert.strictEqual(comments[0].actionCount, 0);
310
assert.strictEqual(comments[0].request, request);
311
});
312
313
test('parses multiple comments from same input', () => {
314
const uri = Uri.file('/test/file.ts');
315
const content = 'line 0\nline 1\nline 2\nline 3\nline 4\nline 5';
316
const snapshot = createTestSnapshot(uri, content);
317
const input: CurrentChangeInput[] = [{
318
document: snapshot,
319
relativeDocumentPath: 'file.ts',
320
change: {
321
repository: {} as any,
322
uri,
323
hunks: [{ range: new Range(0, 0, 5, 6), text: content }]
324
}
325
}];
326
const request = createReviewRequest();
327
const message = `1. Line 2 in \`file.ts\`, bug: First issue.
328
329
2. Line 4 in \`file.ts\`, performance: Second issue.
330
331
`;
332
333
const comments = parseReviewComments(request, input, message);
334
335
assert.strictEqual(comments.length, 2);
336
assert.strictEqual(typeof comments[0].body === 'string' ? comments[0].body : comments[0].body.value, 'First issue.');
337
assert.strictEqual(comments[0].originalIndex, 0);
338
assert.strictEqual(typeof comments[1].body === 'string' ? comments[1].body : comments[1].body.value, 'Second issue.');
339
assert.strictEqual(comments[1].originalIndex, 1);
340
});
341
342
test('filters out unknown kind', () => {
343
const uri = Uri.file('/test/file.ts');
344
const content = 'line 0\nline 1\nline 2';
345
const snapshot = createTestSnapshot(uri, content);
346
const input: CurrentChangeInput[] = [{
347
document: snapshot,
348
relativeDocumentPath: 'file.ts',
349
change: {
350
repository: {} as any,
351
uri,
352
hunks: [{ range: new Range(0, 0, 2, 6), text: content }]
353
}
354
}];
355
const request = createReviewRequest();
356
const message = '1. Line 2 in `file.ts`, unknownKind: Should be filtered.\n\n';
357
358
const comments = parseReviewComments(request, input, message);
359
360
assert.strictEqual(comments.length, 0);
361
});
362
363
test('accepts all known kinds', () => {
364
const knownKinds = ['bug', 'performance', 'consistency', 'documentation', 'naming', 'readability', 'style', 'other'];
365
const uri = Uri.file('/test/file.ts');
366
const content = Array.from({ length: 20 }, (_, i) => `line ${i}`).join('\n');
367
const snapshot = createTestSnapshot(uri, content);
368
const input: CurrentChangeInput[] = [{
369
document: snapshot,
370
relativeDocumentPath: 'file.ts',
371
change: {
372
repository: {} as any,
373
uri,
374
hunks: [{ range: new Range(0, 0, 19, 7), text: content }]
375
}
376
}];
377
const request = createReviewRequest();
378
const message = knownKinds.map((kind, i) => `${i + 1}. Line ${i + 1} in \`file.ts\`, ${kind}: Issue ${i + 1}.`).join('\n\n') + '\n\n';
379
380
const comments = parseReviewComments(request, input, message);
381
382
assert.strictEqual(comments.length, knownKinds.length);
383
knownKinds.forEach((kind, i) => {
384
assert.strictEqual(comments[i].kind, kind);
385
});
386
});
387
388
test('skips comment when relativeDocumentPath does not match any input', () => {
389
const uri = Uri.file('/test/file.ts');
390
const content = 'line 0\nline 1';
391
const snapshot = createTestSnapshot(uri, content);
392
const input: CurrentChangeInput[] = [{
393
document: snapshot,
394
relativeDocumentPath: 'different.ts',
395
change: {
396
repository: {} as any,
397
uri,
398
hunks: [{ range: new Range(0, 0, 1, 6), text: content }]
399
}
400
}];
401
const request = createReviewRequest();
402
const message = '1. Line 1 in `file.ts`, bug: Should be skipped.\n\n';
403
404
const comments = parseReviewComments(request, input, message);
405
406
assert.strictEqual(comments.length, 0);
407
});
408
409
test('matches correct input from multiple inputs', () => {
410
const uri1 = Uri.file('/test/first.ts');
411
const uri2 = Uri.file('/test/second.ts');
412
const content = 'line 0\nline 1';
413
const snapshot1 = createTestSnapshot(uri1, content);
414
const snapshot2 = createTestSnapshot(uri2, content);
415
const input: CurrentChangeInput[] = [
416
{
417
document: snapshot1,
418
relativeDocumentPath: 'first.ts',
419
change: {
420
repository: {} as any,
421
uri: uri1,
422
hunks: [{ range: new Range(0, 0, 1, 6), text: content }]
423
}
424
},
425
{
426
document: snapshot2,
427
relativeDocumentPath: 'second.ts',
428
change: {
429
repository: {} as any,
430
uri: uri2,
431
hunks: [{ range: new Range(0, 0, 1, 6), text: content }]
432
}
433
}
434
];
435
const request = createReviewRequest();
436
const message = '1. Line 1 in `second.ts`, bug: Found in second file.\n\n';
437
438
const comments = parseReviewComments(request, input, message);
439
440
assert.strictEqual(comments.length, 1);
441
assert.strictEqual(comments[0].uri, uri2);
442
});
443
444
test('uses line 0 correctly when Line 1 is specified (0-indexed)', () => {
445
const uri = Uri.file('/test/file.ts');
446
const content = ' indented line\nline 1';
447
const snapshot = createTestSnapshot(uri, content);
448
const input: CurrentChangeInput[] = [{
449
document: snapshot,
450
relativeDocumentPath: 'file.ts',
451
change: {
452
repository: {} as any,
453
uri,
454
hunks: [{ range: new Range(0, 0, 1, 6), text: content }]
455
}
456
}];
457
const request = createReviewRequest();
458
// Line 1 in the message becomes 0 after 0-indexing
459
const message = '1. Line 1 in `file.ts`, bug: First line.\n\n';
460
461
const comments = parseReviewComments(request, input, message);
462
463
assert.strictEqual(comments.length, 1);
464
// Range should start at line 0
465
assert.strictEqual(comments[0].range.start.line, 0);
466
// firstNonWhitespaceCharacterIndex for " indented line" is 2
467
assert.strictEqual(comments[0].range.start.character, 2);
468
});
469
470
test('clamps line number exceeding lineCount', () => {
471
const uri = Uri.file('/test/file.ts');
472
const content = 'line 0\nline 1\nlast line';
473
const snapshot = createTestSnapshot(uri, content);
474
const input: CurrentChangeInput[] = [{
475
document: snapshot,
476
relativeDocumentPath: 'file.ts',
477
change: {
478
repository: {} as any,
479
uri,
480
hunks: [{ range: new Range(0, 0, 2, 9), text: content }]
481
}
482
}];
483
const request = createReviewRequest();
484
// Line 100 is way beyond lineCount of 3
485
const message = '1. Line 1-100 in `file.ts`, bug: Should clamp to end.\n\n';
486
487
const comments = parseReviewComments(request, input, message);
488
489
assert.strictEqual(comments.length, 1);
490
// End line should be clamped to lineCount-1 = 2
491
assert.strictEqual(comments[0].range.end.line, 2);
492
});
493
494
test('filters out comment outside change hunk range', () => {
495
const uri = Uri.file('/test/file.ts');
496
const content = 'line 0\nline 1\nline 2\nline 3\nline 4';
497
const snapshot = createTestSnapshot(uri, content);
498
const input: CurrentChangeInput[] = [{
499
document: snapshot,
500
relativeDocumentPath: 'file.ts',
501
change: {
502
repository: {} as any,
503
uri,
504
// Change only affects lines 0-1
505
hunks: [{ range: new Range(0, 0, 1, 6), text: 'line 0\nline 1' }]
506
}
507
}];
508
const request = createReviewRequest();
509
// Comment on line 4 which is outside the hunk range
510
const message = '1. Line 5 in `file.ts`, bug: Outside hunk range.\n\n';
511
512
const comments = parseReviewComments(request, input, message);
513
514
assert.strictEqual(comments.length, 0);
515
});
516
517
test('uses selection range for filtering when selection is provided', () => {
518
const uri = Uri.file('/test/file.ts');
519
const content = 'line 0\nline 1\nline 2\nline 3\nline 4';
520
const snapshot = createTestSnapshot(uri, content);
521
const selection = new Range(2, 0, 3, 6);
522
const input: CurrentChangeInput[] = [{
523
document: snapshot,
524
relativeDocumentPath: 'file.ts',
525
selection
526
}];
527
const request = createReviewRequest({ inputType: 'selection' });
528
529
// Comment on line 1 (outside selection)
530
const messageOutside = '1. Line 1 in `file.ts`, bug: Outside selection.\n\n';
531
const commentsOutside = parseReviewComments(request, input, messageOutside);
532
assert.strictEqual(commentsOutside.length, 0);
533
534
// Comment on line 3 (inside selection)
535
const messageInside = '1. Line 3 in `file.ts`, bug: Inside selection.\n\n';
536
const commentsInside = parseReviewComments(request, input, messageInside);
537
assert.strictEqual(commentsInside.length, 1);
538
});
539
540
test('includes comment when no filterRanges (no selection or change)', () => {
541
const uri = Uri.file('/test/file.ts');
542
const content = 'line 0\nline 1';
543
const snapshot = createTestSnapshot(uri, content);
544
const input: CurrentChangeInput[] = [{
545
document: snapshot,
546
relativeDocumentPath: 'file.ts'
547
// No selection or change
548
}];
549
const request = createReviewRequest();
550
const message = '1. Line 1 in `file.ts`, bug: No filter ranges.\n\n';
551
552
const comments = parseReviewComments(request, input, message);
553
554
assert.strictEqual(comments.length, 1);
555
});
556
557
test('includes comment when intersecting any of multiple hunks', () => {
558
const uri = Uri.file('/test/file.ts');
559
const content = 'line 0\nline 1\nline 2\nline 3\nline 4\nline 5';
560
const snapshot = createTestSnapshot(uri, content);
561
const input: CurrentChangeInput[] = [{
562
document: snapshot,
563
relativeDocumentPath: 'file.ts',
564
change: {
565
repository: {} as any,
566
uri,
567
hunks: [
568
{ range: new Range(0, 0, 1, 6), text: 'line 0\nline 1' },
569
{ range: new Range(4, 0, 5, 6), text: 'line 4\nline 5' }
570
]
571
}
572
}];
573
const request = createReviewRequest();
574
575
// Comment on line 5 intersects second hunk
576
const message = '1. Line 5 in `file.ts`, bug: In second hunk.\n\n';
577
const comments = parseReviewComments(request, input, message);
578
579
assert.strictEqual(comments.length, 1);
580
});
581
582
test('passes dropPartial to parseFeedbackResponse', () => {
583
const uri = Uri.file('/test/file.ts');
584
const content = 'line 0\nline 1';
585
const snapshot = createTestSnapshot(uri, content);
586
const input: CurrentChangeInput[] = [{
587
document: snapshot,
588
relativeDocumentPath: 'file.ts',
589
change: {
590
repository: {} as any,
591
uri,
592
hunks: [{ range: new Range(0, 0, 1, 6), text: content }]
593
}
594
}];
595
const request = createReviewRequest();
596
// Partial response (no terminating newline)
597
const partialMessage = '1. Line 1 in `file.ts`, bug: Partial';
598
599
// dropPartial = false keeps the partial comment
600
const commentsKept = parseReviewComments(request, input, partialMessage, false);
601
assert.strictEqual(commentsKept.length, 1);
602
603
// dropPartial = true drops the partial comment
604
const commentsDropped = parseReviewComments(request, input, partialMessage, true);
605
assert.strictEqual(commentsDropped.length, 0);
606
});
607
608
test('returns empty array for empty message', () => {
609
const uri = Uri.file('/test/file.ts');
610
const content = 'line 0';
611
const snapshot = createTestSnapshot(uri, content);
612
const input: CurrentChangeInput[] = [{
613
document: snapshot,
614
relativeDocumentPath: 'file.ts',
615
change: {
616
repository: {} as any,
617
uri,
618
hunks: [{ range: new Range(0, 0, 0, 6), text: content }]
619
}
620
}];
621
const request = createReviewRequest();
622
623
const comments = parseReviewComments(request, input, '');
624
625
assert.strictEqual(comments.length, 0);
626
});
627
628
test('returns empty array when no inputs provided', () => {
629
const request = createReviewRequest();
630
const message = '1. Line 1 in `file.ts`, bug: No inputs.\n\n';
631
632
const comments = parseReviewComments(request, [], message);
633
634
assert.strictEqual(comments.length, 0);
635
});
636
637
test('sets correct range with firstNonWhitespaceCharacterIndex', () => {
638
const uri = Uri.file('/test/file.ts');
639
const content = ' indented content here';
640
const snapshot = createTestSnapshot(uri, content);
641
const input: CurrentChangeInput[] = [{
642
document: snapshot,
643
relativeDocumentPath: 'file.ts',
644
change: {
645
repository: {} as any,
646
uri,
647
hunks: [{ range: new Range(0, 0, 0, 25), text: content }]
648
}
649
}];
650
const request = createReviewRequest();
651
const message = '1. Line 1 in `file.ts`, bug: Indented line.\n\n';
652
653
const comments = parseReviewComments(request, input, message);
654
655
assert.strictEqual(comments.length, 1);
656
// Start character should be firstNonWhitespaceCharacterIndex (4 spaces)
657
assert.strictEqual(comments[0].range.start.character, 4);
658
// End character should be lastNonWhitespaceCharacterIndex (25 - no trailing whitespace)
659
assert.strictEqual(comments[0].range.end.character, 25);
660
});
661
662
test('handles line range spanning multiple lines', () => {
663
const uri = Uri.file('/test/file.ts');
664
const content = ' line 0\nline 1\n line 2 with trailing ';
665
const snapshot = createTestSnapshot(uri, content);
666
const input: CurrentChangeInput[] = [{
667
document: snapshot,
668
relativeDocumentPath: 'file.ts',
669
change: {
670
repository: {} as any,
671
uri,
672
hunks: [{ range: new Range(0, 0, 2, 27), text: content }]
673
}
674
}];
675
const request = createReviewRequest();
676
const message = '1. Line 1-3 in `file.ts`, bug: Multi-line issue.\n\n';
677
678
const comments = parseReviewComments(request, input, message);
679
680
assert.strictEqual(comments.length, 1);
681
// Start: line 0, firstNonWhitespaceCharacterIndex = 2
682
assert.strictEqual(comments[0].range.start.line, 0);
683
assert.strictEqual(comments[0].range.start.character, 2);
684
// End: line 2, lastNonWhitespaceCharacterIndex for " line 2 with trailing " is 22 (trimEnd removes trailing spaces)
685
assert.strictEqual(comments[0].range.end.line, 2);
686
assert.strictEqual(comments[0].range.end.character, 22);
687
});
688
689
test('preserves document reference in comment', () => {
690
const uri = Uri.file('/test/file.ts');
691
const content = 'line 0';
692
const snapshot = createTestSnapshot(uri, content);
693
const input: CurrentChangeInput[] = [{
694
document: snapshot,
695
relativeDocumentPath: 'file.ts',
696
change: {
697
repository: {} as any,
698
uri,
699
hunks: [{ range: new Range(0, 0, 0, 6), text: content }]
700
}
701
}];
702
const request = createReviewRequest();
703
const message = '1. Line 1 in `file.ts`, bug: Check document.\n\n';
704
705
const comments = parseReviewComments(request, input, message);
706
707
assert.strictEqual(comments.length, 1);
708
assert.strictEqual(comments[0].document, snapshot);
709
});
710
});
711
712
class MockIgnoreService extends NullIgnoreService {
713
override get isEnabled(): boolean { return true; }
714
override get isRegexExclusionsEnabled(): boolean { return true; }
715
716
private _ignoredUris = new Set<string>();
717
private _alwaysIgnore = false;
718
719
override async isCopilotIgnored(file: Uri): Promise<boolean> {
720
if (this._alwaysIgnore) {
721
return true;
722
}
723
return this._ignoredUris.has(file.toString());
724
}
725
726
setAlwaysIgnore(): void {
727
this._alwaysIgnore = true;
728
}
729
730
setIgnoredUris(uris: Uri[]): void {
731
this._ignoredUris = new Set(uris.map(u => u.toString()));
732
}
733
734
reset(): void {
735
this._alwaysIgnore = false;
736
this._ignoredUris.clear();
737
}
738
}
739
740
class MockChatEndpoint {
741
model = 'gpt-4.1-test';
742
family = 'gpt-4.1';
743
name = 'Test Endpoint';
744
maxOutputTokens = 8000;
745
modelMaxPromptTokens = 128000;
746
supportsToolCalls = true;
747
supportsVision = true;
748
supportsPrediction = true;
749
showInModelPicker = true;
750
isDefault = true;
751
isFallback = false;
752
policy: 'enabled' | { terms: string } = 'enabled';
753
urlOrRequestMetadata = 'https://test.com';
754
version = '1.0';
755
tokenizer = 'o200k_base';
756
757
private _response: ChatResponse = { type: ChatFetchResponseType.Success, value: '', requestId: 'test-request-id', serverRequestId: undefined, usage: undefined, resolvedModel: 'gpt-4.1-test' };
758
759
setResponse(response: ChatResponse): void {
760
this._response = response;
761
}
762
763
async makeChatRequest(
764
_debugName: string,
765
_messages: Raw.ChatMessage[],
766
finishedCb: ((text: string) => Promise<void>) | undefined,
767
_token: CancellationToken,
768
): Promise<ChatResponse> {
769
if (this._response.type === ChatFetchResponseType.Success && finishedCb) {
770
await finishedCb(this._response.value);
771
}
772
return this._response;
773
}
774
775
acquireTokenizer(): any {
776
return {
777
tokenize: (text: string) => ({ bpe: text.split(' ').map((_, i) => i), text }),
778
tokenLength: (text: string) => Math.ceil(text.length / 4),
779
encode: (text: string) => text.split(' ').map((_, i) => i),
780
decode: (tokens: number[]) => tokens.join(' '),
781
};
782
}
783
}
784
785
class MockEndpointProvider implements IEndpointProvider {
786
declare readonly _serviceBrand: undefined;
787
readonly onDidModelsRefresh = Event.None;
788
789
private _endpoint = new MockChatEndpoint();
790
791
get mockEndpoint(): MockChatEndpoint {
792
return this._endpoint;
793
}
794
795
async getChatEndpoint(): Promise<IChatEndpoint> {
796
return this._endpoint as unknown as IChatEndpoint;
797
}
798
799
async getEmbeddingsEndpoint(): Promise<any> {
800
throw new Error('Not implemented');
801
}
802
803
async getAllChatEndpoints(): Promise<IChatEndpoint[]> {
804
return [this._endpoint as unknown as IChatEndpoint];
805
}
806
807
async getAllCompletionModels(): Promise<any[]> {
808
return [];
809
}
810
}
811
812
describe('FeedbackGenerator.generateComments', () => {
813
let disposables: DisposableStore;
814
let mockIgnoreService: MockIgnoreService;
815
let mockEndpointProvider: MockEndpointProvider;
816
let feedbackGenerator: FeedbackGenerator;
817
let instantiationService: IInstantiationService;
818
819
beforeEach(() => {
820
disposables = new DisposableStore();
821
mockIgnoreService = new MockIgnoreService();
822
mockEndpointProvider = new MockEndpointProvider();
823
824
const serviceCollection = disposables.add(createExtensionUnitTestingServices());
825
serviceCollection.define(IIgnoreService, mockIgnoreService);
826
serviceCollection.define(IEndpointProvider, mockEndpointProvider);
827
serviceCollection.define(ITelemetryService, new NullTelemetryService());
828
instantiationService = serviceCollection.createTestingAccessor().get(IInstantiationService);
829
feedbackGenerator = instantiationService.createInstance(FeedbackGenerator);
830
});
831
832
afterEach(() => {
833
disposables.dispose();
834
});
835
836
function createInput(
837
uri: Uri,
838
content: string,
839
relativeDocumentPath: string,
840
options?: { selection?: Range; hunks?: { range: Range; text: string }[] }
841
): CurrentChangeInput {
842
const snapshot = createTestSnapshot(uri, content);
843
const input: CurrentChangeInput = {
844
document: snapshot,
845
relativeDocumentPath,
846
};
847
if (options?.selection) {
848
input.selection = options.selection;
849
}
850
if (options?.hunks) {
851
input.change = {
852
repository: {} as any,
853
uri,
854
hunks: options.hunks,
855
};
856
}
857
return input;
858
}
859
860
test('returns success with comments when endpoint returns valid response', async () => {
861
const uri = Uri.file('/test/file.ts');
862
const content = 'line 0\nline 1\nline 2\nline 3\nline 4';
863
const input = [createInput(uri, content, 'file.ts', {
864
hunks: [{ range: new Range(0, 0, 4, 6), text: content }]
865
})];
866
867
mockEndpointProvider.mockEndpoint.setResponse({
868
type: ChatFetchResponseType.Success,
869
value: '1. Line 2 in `file.ts`, bug, high severity: This is a bug.\n\n',
870
requestId: 'test-request-id',
871
serverRequestId: undefined,
872
usage: undefined,
873
resolvedModel: 'gpt-4.1-test'
874
});
875
876
const result = await feedbackGenerator.generateComments(input, CancellationToken.None);
877
878
assert.strictEqual(result.type, 'success');
879
if (result.type === 'success') {
880
assert.strictEqual(result.comments.length, 1);
881
assert.strictEqual(result.comments[0].kind, 'bug');
882
assert.strictEqual(result.comments[0].severity, 'high');
883
}
884
});
885
886
test('returns success with empty comments when endpoint returns no comments', async () => {
887
const uri = Uri.file('/test/file.ts');
888
const content = 'line 0\nline 1';
889
const input = [createInput(uri, content, 'file.ts', {
890
hunks: [{ range: new Range(0, 0, 1, 6), text: content }]
891
})];
892
893
mockEndpointProvider.mockEndpoint.setResponse({
894
type: ChatFetchResponseType.Success,
895
value: 'No issues found in this code.',
896
requestId: 'test-request-id',
897
serverRequestId: undefined,
898
usage: undefined,
899
resolvedModel: 'gpt-4.1-test'
900
});
901
902
const result = await feedbackGenerator.generateComments(input, CancellationToken.None);
903
904
assert.strictEqual(result.type, 'success');
905
if (result.type === 'success') {
906
assert.strictEqual(result.comments.length, 0);
907
}
908
});
909
910
test('returns multiple comments from single response', async () => {
911
const uri = Uri.file('/test/file.ts');
912
const content = 'line 0\nline 1\nline 2\nline 3\nline 4\nline 5';
913
const input = [createInput(uri, content, 'file.ts', {
914
hunks: [{ range: new Range(0, 0, 5, 6), text: content }]
915
})];
916
917
mockEndpointProvider.mockEndpoint.setResponse({
918
type: ChatFetchResponseType.Success,
919
value: `1. Line 2 in \`file.ts\`, bug, high severity: First bug.
920
921
2. Line 4 in \`file.ts\`, performance, medium severity: Performance issue.
922
923
`,
924
requestId: 'test-request-id',
925
serverRequestId: undefined,
926
usage: undefined,
927
resolvedModel: 'gpt-4.1-test'
928
});
929
930
const result = await feedbackGenerator.generateComments(input, CancellationToken.None);
931
932
assert.strictEqual(result.type, 'success');
933
if (result.type === 'success') {
934
assert.strictEqual(result.comments.length, 2);
935
assert.strictEqual(result.comments[0].kind, 'bug');
936
assert.strictEqual(result.comments[1].kind, 'performance');
937
}
938
});
939
940
test('returns error when all inputs are ignored', async () => {
941
const uri = Uri.file('/test/file.ts');
942
const content = 'line 0\nline 1';
943
const input = [createInput(uri, content, 'file.ts', {
944
hunks: [{ range: new Range(0, 0, 1, 6), text: content }]
945
})];
946
947
mockIgnoreService.setAlwaysIgnore();
948
949
const result = await feedbackGenerator.generateComments(input, CancellationToken.None);
950
951
assert.strictEqual(result.type, 'error');
952
if (result.type === 'error') {
953
assert.strictEqual(result.severity, 'info');
954
assert.ok(result.reason.includes('ignored'));
955
}
956
});
957
958
test('filters out ignored documents but processes non-ignored ones', async () => {
959
const uri1 = Uri.file('/test/ignored.ts');
960
const uri2 = Uri.file('/test/allowed.ts');
961
const content = 'line 0\nline 1';
962
const input = [
963
createInput(uri1, content, 'ignored.ts', {
964
hunks: [{ range: new Range(0, 0, 1, 6), text: content }]
965
}),
966
createInput(uri2, content, 'allowed.ts', {
967
hunks: [{ range: new Range(0, 0, 1, 6), text: content }]
968
})
969
];
970
971
mockIgnoreService.setIgnoredUris([uri1]);
972
973
mockEndpointProvider.mockEndpoint.setResponse({
974
type: ChatFetchResponseType.Success,
975
value: '1. Line 1 in `allowed.ts`, bug: Issue in allowed file.\n\n',
976
requestId: 'test-request-id',
977
serverRequestId: undefined,
978
usage: undefined,
979
resolvedModel: 'gpt-4.1-test'
980
});
981
982
const result = await feedbackGenerator.generateComments(input, CancellationToken.None);
983
984
assert.strictEqual(result.type, 'success');
985
if (result.type === 'success') {
986
assert.strictEqual(result.comments.length, 1);
987
assert.strictEqual(result.comments[0].uri.toString(), uri2.toString());
988
}
989
});
990
991
test('returns cancelled when token is cancelled before request', async () => {
992
const uri = Uri.file('/test/file.ts');
993
const content = 'line 0\nline 1';
994
const input = [createInput(uri, content, 'file.ts', {
995
hunks: [{ range: new Range(0, 0, 1, 6), text: content }]
996
})];
997
998
const tokenSource = new CancellationTokenSource();
999
tokenSource.cancel();
1000
1001
const result = await feedbackGenerator.generateComments(input, tokenSource.token);
1002
1003
assert.strictEqual(result.type, 'cancelled');
1004
});
1005
1006
test('returns error when endpoint returns error', async () => {
1007
const uri = Uri.file('/test/file.ts');
1008
const content = 'line 0\nline 1';
1009
const input = [createInput(uri, content, 'file.ts', {
1010
hunks: [{ range: new Range(0, 0, 1, 6), text: content }]
1011
})];
1012
1013
mockEndpointProvider.mockEndpoint.setResponse({
1014
type: ChatFetchResponseType.Failed,
1015
reason: 'API error',
1016
requestId: 'test-request-id',
1017
serverRequestId: undefined
1018
});
1019
1020
const result = await feedbackGenerator.generateComments(input, CancellationToken.None);
1021
1022
assert.strictEqual(result.type, 'error');
1023
if (result.type === 'error') {
1024
assert.strictEqual(result.reason, 'API error');
1025
}
1026
});
1027
1028
test('reports progress when progress callback is provided', async () => {
1029
const uri = Uri.file('/test/file.ts');
1030
const content = 'line 0\nline 1\nline 2\nline 3\nline 4';
1031
const input = [createInput(uri, content, 'file.ts', {
1032
hunks: [{ range: new Range(0, 0, 4, 6), text: content }]
1033
})];
1034
1035
mockEndpointProvider.mockEndpoint.setResponse({
1036
type: ChatFetchResponseType.Success,
1037
value: '1. Line 2 in `file.ts`, bug, high severity: This is a bug.\n\n',
1038
requestId: 'test-request-id',
1039
serverRequestId: undefined,
1040
usage: undefined,
1041
resolvedModel: 'gpt-4.1-test'
1042
});
1043
1044
const reportedComments: ReviewComment[][] = [];
1045
const progress = {
1046
report: (comments: ReviewComment[]) => {
1047
reportedComments.push(comments);
1048
}
1049
};
1050
1051
await feedbackGenerator.generateComments(input, CancellationToken.None, progress);
1052
1053
// Progress should have been reported at least once
1054
assert.ok(reportedComments.length > 0);
1055
});
1056
1057
test('handles selection input correctly', async () => {
1058
const uri = Uri.file('/test/file.ts');
1059
const content = 'line 0\nline 1\nline 2\nline 3\nline 4';
1060
const input = [createInput(uri, content, 'file.ts', {
1061
selection: new Range(1, 0, 3, 6)
1062
})];
1063
1064
mockEndpointProvider.mockEndpoint.setResponse({
1065
type: ChatFetchResponseType.Success,
1066
value: '1. Line 2 in `file.ts`, bug: Selection issue.\n\n',
1067
requestId: 'test-request-id',
1068
serverRequestId: undefined,
1069
usage: undefined,
1070
resolvedModel: 'gpt-4.1-test'
1071
});
1072
1073
const result = await feedbackGenerator.generateComments(input, CancellationToken.None);
1074
1075
assert.strictEqual(result.type, 'success');
1076
if (result.type === 'success') {
1077
assert.strictEqual(result.comments.length, 1);
1078
}
1079
});
1080
1081
test('handles multiple files correctly', async () => {
1082
const uri1 = Uri.file('/test/first.ts');
1083
const uri2 = Uri.file('/test/second.ts');
1084
const content = 'line 0\nline 1';
1085
const input = [
1086
createInput(uri1, content, 'first.ts', {
1087
hunks: [{ range: new Range(0, 0, 1, 6), text: content }]
1088
}),
1089
createInput(uri2, content, 'second.ts', {
1090
hunks: [{ range: new Range(0, 0, 1, 6), text: content }]
1091
})
1092
];
1093
1094
mockEndpointProvider.mockEndpoint.setResponse({
1095
type: ChatFetchResponseType.Success,
1096
value: `1. Line 1 in \`first.ts\`, bug: Issue in first file.
1097
1098
2. Line 1 in \`second.ts\`, performance: Issue in second file.
1099
1100
`,
1101
requestId: 'test-request-id',
1102
serverRequestId: undefined,
1103
usage: undefined,
1104
resolvedModel: 'gpt-4.1-test'
1105
});
1106
1107
const result = await feedbackGenerator.generateComments(input, CancellationToken.None);
1108
1109
assert.strictEqual(result.type, 'success');
1110
if (result.type === 'success') {
1111
assert.strictEqual(result.comments.length, 2);
1112
assert.strictEqual(result.comments[0].uri.toString(), uri1.toString());
1113
assert.strictEqual(result.comments[1].uri.toString(), uri2.toString());
1114
}
1115
});
1116
1117
test('handles empty input array', async () => {
1118
const result = await feedbackGenerator.generateComments([], CancellationToken.None);
1119
1120
assert.strictEqual(result.type, 'error');
1121
if (result.type === 'error') {
1122
assert.strictEqual(result.severity, 'info');
1123
}
1124
});
1125
1126
test('handles input with no changes or selection', async () => {
1127
const uri = Uri.file('/test/file.ts');
1128
const content = 'line 0\nline 1';
1129
const input = [createInput(uri, content, 'file.ts')];
1130
1131
mockEndpointProvider.mockEndpoint.setResponse({
1132
type: ChatFetchResponseType.Success,
1133
value: '1. Line 1 in `file.ts`, bug: Issue.\n\n',
1134
requestId: 'test-request-id',
1135
serverRequestId: undefined,
1136
usage: undefined,
1137
resolvedModel: 'gpt-4.1-test'
1138
});
1139
1140
const result = await feedbackGenerator.generateComments(input, CancellationToken.None);
1141
1142
assert.strictEqual(result.type, 'success');
1143
});
1144
1145
test('returns error when prompts exceed maxPrompts limit', async () => {
1146
// Create many inputs that will each become a separate prompt after splitting
1147
const inputs: CurrentChangeInput[] = [];
1148
for (let i = 0; i < 15; i++) {
1149
const uri = Uri.file(`/test/file${i}.ts`);
1150
const content = 'line 0\nline 1';
1151
inputs.push(createInput(uri, content, `file${i}.ts`, {
1152
hunks: [{ range: new Range(0, 0, 1, 6), text: content }]
1153
}));
1154
}
1155
1156
// Mock the endpoint to track calls
1157
let callCount = 0;
1158
const originalMakeChatRequest = mockEndpointProvider.mockEndpoint.makeChatRequest.bind(mockEndpointProvider.mockEndpoint);
1159
mockEndpointProvider.mockEndpoint.makeChatRequest = async (debugName, messages, finishedCb, token) => {
1160
callCount++;
1161
return originalMakeChatRequest(debugName, messages, finishedCb, token);
1162
};
1163
1164
// Since we can't easily mock the PromptRenderer to throw split_input,
1165
// we test the error message when inputType is 'selection' vs 'change'
1166
// The actual maxPrompts > 10 is hard to trigger without mocking PromptRenderer
1167
// This test documents the expected behavior
1168
const result = await feedbackGenerator.generateComments(inputs, CancellationToken.None);
1169
1170
// With 15 files that don't cause split_input, they should be processed
1171
// If the batch was split enough times (>10 prompts), we'd get an error
1172
assert.ok(result.type === 'success' || result.type === 'error');
1173
});
1174
});
1175
1176
class MockLogService extends TestLogService {
1177
readonly debugMessages: string[] = [];
1178
readonly warnMessages: string[] = [];
1179
1180
override debug(message: string): void { this.debugMessages.push(message); }
1181
override warn(message: string): void { this.warnMessages.push(message); }
1182
1183
reset(): void {
1184
this.debugMessages.length = 0;
1185
this.warnMessages.length = 0;
1186
}
1187
}
1188
1189
interface TelemetryCall {
1190
eventName: string;
1191
properties?: TelemetryEventProperties;
1192
measurements?: TelemetryEventMeasurements;
1193
}
1194
1195
class MockTelemetryService extends NullTelemetryService {
1196
readonly msftEvents: TelemetryCall[] = [];
1197
readonly internalMsftEvents: TelemetryCall[] = [];
1198
1199
override sendMSFTTelemetryEvent(eventName: string, properties?: TelemetryEventProperties, measurements?: TelemetryEventMeasurements): void {
1200
this.msftEvents.push({ eventName, properties, measurements });
1201
}
1202
1203
override sendInternalMSFTTelemetryEvent(eventName: string, properties?: TelemetryEventProperties, measurements?: TelemetryEventMeasurements): void {
1204
this.internalMsftEvents.push({ eventName, properties, measurements });
1205
}
1206
1207
reset(): void {
1208
this.msftEvents.length = 0;
1209
this.internalMsftEvents.length = 0;
1210
}
1211
}
1212
1213
describe('sendReviewActionTelemetry', () => {
1214
let mockLogService: MockLogService;
1215
let mockTelemetryService: MockTelemetryService;
1216
let mockInstantiationService: IInstantiationService;
1217
let disposables: DisposableStore;
1218
1219
function createTestReviewComment(overrides?: Partial<ReviewComment>): ReviewComment {
1220
const uri = Uri.file('/test/file.ts');
1221
const content = 'line 0\nline 1\nline 2';
1222
const docData = createTextDocumentData(uri, content, 'typescript');
1223
const snapshot = TextDocumentSnapshot.create(docData.document);
1224
1225
return {
1226
request: {
1227
source: 'vscodeCopilotChat',
1228
promptCount: 1,
1229
messageId: 'test-message-id',
1230
inputType: 'change',
1231
inputRanges: [{ uri, ranges: [new Range(0, 0, 2, 6)] }],
1232
},
1233
document: snapshot,
1234
uri,
1235
languageId: 'typescript',
1236
range: new Range(1, 0, 1, 6),
1237
body: new MarkdownString('Test comment body'),
1238
kind: 'bug',
1239
severity: 'high',
1240
originalIndex: 0,
1241
actionCount: 0,
1242
...overrides,
1243
};
1244
}
1245
1246
beforeEach(() => {
1247
disposables = new DisposableStore();
1248
mockLogService = new MockLogService();
1249
mockTelemetryService = new MockTelemetryService();
1250
1251
const serviceCollection = disposables.add(createExtensionUnitTestingServices());
1252
mockInstantiationService = serviceCollection.createTestingAccessor().get(IInstantiationService);
1253
});
1254
1255
afterEach(() => {
1256
disposables.dispose();
1257
});
1258
1259
test.each([
1260
['helpful', 5],
1261
['unhelpful', 3],
1262
] as const)('sends review.comment.vote telemetry for %s action', (action, totalComments) => {
1263
const comment = createTestReviewComment();
1264
1265
sendReviewActionTelemetry(comment, totalComments, action, mockLogService, mockTelemetryService, mockInstantiationService);
1266
1267
assert.strictEqual(mockLogService.debugMessages.length, 1);
1268
assert.ok(mockLogService.debugMessages[0].includes('user feedback received'));
1269
1270
assert.strictEqual(mockTelemetryService.msftEvents.length, 1);
1271
assert.strictEqual(mockTelemetryService.msftEvents[0].eventName, 'review.comment.vote');
1272
assert.strictEqual(mockTelemetryService.msftEvents[0].properties?.userAction, action);
1273
assert.strictEqual(mockTelemetryService.msftEvents[0].properties?.commentType, 'bug');
1274
assert.strictEqual(mockTelemetryService.msftEvents[0].measurements?.totalComments, totalComments);
1275
1276
assert.strictEqual(mockTelemetryService.internalMsftEvents.length, 1);
1277
assert.strictEqual(mockTelemetryService.internalMsftEvents[0].eventName, 'review.comment.vote');
1278
});
1279
1280
test('does not increment actionCount for vote actions', () => {
1281
const comment = createTestReviewComment({ actionCount: 2 });
1282
1283
sendReviewActionTelemetry(comment, 1, 'helpful', mockLogService, mockTelemetryService, mockInstantiationService);
1284
1285
assert.strictEqual(comment.actionCount, 2);
1286
});
1287
1288
test('sends review.comment.action telemetry for apply action', () => {
1289
const comment = createTestReviewComment();
1290
1291
sendReviewActionTelemetry(comment, 2, 'apply', mockLogService, mockTelemetryService, mockInstantiationService);
1292
1293
assert.strictEqual(mockTelemetryService.msftEvents.length, 1);
1294
assert.strictEqual(mockTelemetryService.msftEvents[0].eventName, 'review.comment.action');
1295
assert.strictEqual(mockTelemetryService.msftEvents[0].properties?.userAction, 'apply');
1296
});
1297
1298
test('increments actionCount for non-vote actions', () => {
1299
const comment = createTestReviewComment({ actionCount: 0 });
1300
1301
sendReviewActionTelemetry(comment, 1, 'apply', mockLogService, mockTelemetryService, mockInstantiationService);
1302
1303
assert.strictEqual(comment.actionCount, 1);
1304
});
1305
1306
test('increments actionCount multiple times for multiple actions', () => {
1307
const comment = createTestReviewComment({ actionCount: 0 });
1308
1309
sendReviewActionTelemetry(comment, 1, 'apply', mockLogService, mockTelemetryService, mockInstantiationService);
1310
sendReviewActionTelemetry(comment, 1, 'discard', mockLogService, mockTelemetryService, mockInstantiationService);
1311
1312
assert.strictEqual(comment.actionCount, 2);
1313
});
1314
1315
test('returns early and warns when no comments provided', () => {
1316
sendReviewActionTelemetry([], 0, 'helpful', mockLogService, mockTelemetryService, mockInstantiationService);
1317
1318
assert.strictEqual(mockLogService.warnMessages.length, 1);
1319
assert.ok(mockLogService.warnMessages[0].includes('No review comment found'));
1320
assert.strictEqual(mockTelemetryService.msftEvents.length, 0);
1321
});
1322
1323
test('handles single comment (not array)', () => {
1324
const comment = createTestReviewComment();
1325
1326
sendReviewActionTelemetry(comment, 1, 'helpful', mockLogService, mockTelemetryService, mockInstantiationService);
1327
1328
assert.strictEqual(mockTelemetryService.msftEvents.length, 1);
1329
assert.strictEqual(mockTelemetryService.msftEvents[0].measurements?.comments, 1);
1330
});
1331
1332
test('handles array of comments', () => {
1333
const comment1 = createTestReviewComment({ originalIndex: 0 });
1334
const comment2 = createTestReviewComment({ originalIndex: 1, body: new MarkdownString('Second comment') });
1335
1336
sendReviewActionTelemetry([comment1, comment2], 3, 'helpful', mockLogService, mockTelemetryService, mockInstantiationService);
1337
1338
assert.strictEqual(mockTelemetryService.msftEvents.length, 1);
1339
assert.strictEqual(mockTelemetryService.msftEvents[0].measurements?.comments, 2);
1340
});
1341
1342
test('uses unknown for unrecognized comment kind', () => {
1343
const comment = createTestReviewComment({ kind: 'unknownKind' });
1344
1345
sendReviewActionTelemetry(comment, 1, 'helpful', mockLogService, mockTelemetryService, mockInstantiationService);
1346
1347
assert.strictEqual(mockTelemetryService.msftEvents[0].properties?.commentType, 'unknown');
1348
});
1349
1350
test('calculates commentLength correctly for MarkdownString body', () => {
1351
const comment = createTestReviewComment({ body: new MarkdownString('Hello world') });
1352
1353
sendReviewActionTelemetry(comment, 1, 'helpful', mockLogService, mockTelemetryService, mockInstantiationService);
1354
1355
assert.strictEqual(mockTelemetryService.msftEvents[0].measurements?.commentLength, 11);
1356
});
1357
1358
test('calculates commentLength correctly for string body', () => {
1359
const comment = createTestReviewComment({ body: 'Plain text body' });
1360
1361
sendReviewActionTelemetry(comment, 1, 'helpful', mockLogService, mockTelemetryService, mockInstantiationService);
1362
1363
assert.strictEqual(mockTelemetryService.msftEvents[0].measurements?.commentLength, 15);
1364
});
1365
1366
test('calculates inputLineCount correctly from multiple ranges', () => {
1367
const uri = Uri.file('/test/file.ts');
1368
const comment = createTestReviewComment({
1369
request: {
1370
source: 'vscodeCopilotChat',
1371
promptCount: 1,
1372
messageId: 'test-message-id',
1373
inputType: 'change',
1374
inputRanges: [
1375
{ uri, ranges: [new Range(0, 0, 5, 0), new Range(10, 0, 15, 0)] },
1376
{ uri, ranges: [new Range(20, 0, 25, 0)] },
1377
],
1378
},
1379
});
1380
1381
sendReviewActionTelemetry(comment, 1, 'helpful', mockLogService, mockTelemetryService, mockInstantiationService);
1382
1383
// (5-0) + (15-10) + (25-20) = 5 + 5 + 5 = 15
1384
assert.strictEqual(mockTelemetryService.msftEvents[0].measurements?.inputLineCount, 15);
1385
});
1386
1387
test('includes all expected properties', () => {
1388
const comment = createTestReviewComment();
1389
1390
sendReviewActionTelemetry(comment, 2, 'helpful', mockLogService, mockTelemetryService, mockInstantiationService);
1391
1392
const props = mockTelemetryService.msftEvents[0].properties;
1393
assert.strictEqual(props?.source, 'vscodeCopilotChat');
1394
assert.strictEqual(props?.requestId, 'test-message-id');
1395
assert.strictEqual(props?.documentType, 'text');
1396
assert.strictEqual(props?.languageId, 'typescript');
1397
assert.strictEqual(props?.inputType, 'change');
1398
assert.strictEqual(props?.commentType, 'bug');
1399
assert.strictEqual(props?.userAction, 'helpful');
1400
});
1401
1402
test('includes all expected measurements', () => {
1403
const comment = createTestReviewComment({ originalIndex: 3, actionCount: 2 });
1404
1405
sendReviewActionTelemetry(comment, 10, 'helpful', mockLogService, mockTelemetryService, mockInstantiationService);
1406
1407
const measures = mockTelemetryService.msftEvents[0].measurements;
1408
assert.strictEqual(measures?.commentIndex, 3);
1409
assert.strictEqual(measures?.actionCount, 2);
1410
assert.strictEqual(measures?.inputDocumentCount, 1);
1411
assert.strictEqual(measures?.promptCount, 1);
1412
assert.strictEqual(measures?.totalComments, 10);
1413
assert.strictEqual(measures?.comments, 1);
1414
});
1415
1416
test('triggers EditSurvivalReporter for discardComment action', () => {
1417
const comment = createTestReviewComment();
1418
1419
// Note: discardComment action tries to create EditSurvivalReporter which requires
1420
// additional services not available in unit tests. This test verifies the telemetry
1421
// path is correct before that point.
1422
try {
1423
sendReviewActionTelemetry(comment, 1, 'discardComment', mockLogService, mockTelemetryService, mockInstantiationService);
1424
} catch {
1425
// Expected: EditSurvivalReporter instantiation fails in unit test context
1426
}
1427
1428
// discardComment is a non-vote action, so actionCount should be incremented
1429
assert.strictEqual(comment.actionCount, 1);
1430
1431
// Should send review.comment.action telemetry (not vote)
1432
assert.strictEqual(mockTelemetryService.msftEvents.length, 1);
1433
assert.strictEqual(mockTelemetryService.msftEvents[0].eventName, 'review.comment.action');
1434
assert.strictEqual(mockTelemetryService.msftEvents[0].properties?.userAction, 'discardComment');
1435
});
1436
});
1437
});
1438
1439