Path: blob/main/extensions/copilot/src/extension/prompt/node/test/feedbackGenerator.spec.ts
13405 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { Raw } from '@vscode/prompt-tsx';6import assert from 'assert';7import { afterEach, beforeEach, describe, suite, test } from 'vitest';8import { ChatFetchResponseType, ChatResponse } from '../../../../platform/chat/common/commonTypes';9import { TextDocumentSnapshot } from '../../../../platform/editing/common/textDocumentSnapshot';10import { IEndpointProvider } from '../../../../platform/endpoint/common/endpointProvider';11import { IIgnoreService, NullIgnoreService } from '../../../../platform/ignore/common/ignoreService';12import { IChatEndpoint } from '../../../../platform/networking/common/networking';13import { ReviewComment, ReviewRequest } from '../../../../platform/review/common/reviewService';14import { NullTelemetryService } from '../../../../platform/telemetry/common/nullTelemetryService';15import { ITelemetryService, TelemetryEventMeasurements, TelemetryEventProperties } from '../../../../platform/telemetry/common/telemetry';16import { TestLogService } from '../../../../platform/testing/common/testLogService';17import { createTextDocumentData } from '../../../../util/common/test/shims/textDocument';18import { CancellationToken, CancellationTokenSource } from '../../../../util/vs/base/common/cancellation';19import { Event } from '../../../../util/vs/base/common/event';20import { DisposableStore } from '../../../../util/vs/base/common/lifecycle';21import * as path from '../../../../util/vs/base/common/path';22import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation';23import { MarkdownString, Range, Uri } from '../../../../vscodeTypes';24import { CurrentChangeInput } from '../../../prompts/node/feedback/currentChange';25import { createExtensionUnitTestingServices } from '../../../test/node/services';26import { FeedbackGenerator, parseFeedbackResponse, parseReviewComments, sendReviewActionTelemetry } from '../feedbackGenerator';2728suite('feedbackGenerator', () => {2930function createTestSnapshot(uri: Uri, content: string, languageId = 'typescript'): TextDocumentSnapshot {31const docData = createTextDocumentData(uri, content, languageId);32return TextDocumentSnapshot.create(docData.document);33}3435function createReviewRequest(overrides?: Partial<ReviewRequest>): ReviewRequest {36return {37source: 'vscodeCopilotChat',38promptCount: 1,39messageId: 'test-message-id',40inputType: 'change',41inputRanges: [],42...overrides,43};44}4546describe('parseFeedbackResponse', () => {4748// Legacy test case before refactor49test('Correctly parses reply', function () {50const 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.51\`\`\`typescript52this._register(workspace.registerTextDocumentContentProvider(ChatRequestScheme.chatRequestScheme, {53onDidChange: Event.map(this.onDidChangeRequests, this._mapToLatestUri),54provideTextDocumentContent: (uri) => {55const uriData = ChatRequestScheme.parseUri(uri.toString());56if (!uriData) { return \`Invalid URI: \${uri}\`; }5758const entry = uriData.kind === 'latest' ? this._entries[this._entries.length - 1] : this._entries.find(e => e.id === uriData.id);59if (!entry) { return \`Request not found\`; }6061if (entry.kind === LoggedInfoKind.Element) { return entry.html; }6263return this._renderEntryToMarkdown(entry.id, entry.entry);64}65}));6667private _mapToLatestUri = () => Uri.parse(ChatRequestScheme.buildUri({ kind: 'latest' }));68\`\`\``;69const matches = parseFeedbackResponse(fileContents);70assert.strictEqual(matches.length, 1);71assert.strictEqual(matches[0].from, 32);72assert.strictEqual(matches[0].content.indexOf('```'), -1);73});7475test('parses single comment with all fields including linkOffset and linkLength', () => {76const response = '1. Line 10 in `file.ts`, bug, high severity: This is a bug.';77const matches = parseFeedbackResponse(response);7879assert.strictEqual(matches.length, 1);80assert.strictEqual(matches[0].from, 9); // 0-indexed81assert.strictEqual(matches[0].to, 10);82assert.strictEqual(matches[0].relativeDocumentPath, 'file.ts');83assert.strictEqual(matches[0].kind, 'bug');84assert.strictEqual(matches[0].severity, 'high');85assert.strictEqual(matches[0].content, 'This is a bug.');86// linkOffset = match.index + num.length + 2 = 0 + 1 + 2 = 387assert.strictEqual(matches[0].linkOffset, 3);88// linkLength = 5 ("Line ") + from.length (2 for "10") = 789assert.strictEqual(matches[0].linkLength, 7);90});9192test('parses comment without backticks around path', () => {93const response = '1. Line 5 in file.ts, performance, medium severity: Slow code.';94const matches = parseFeedbackResponse(response);9596assert.strictEqual(matches.length, 1);97assert.strictEqual(matches[0].relativeDocumentPath, 'file.ts');98});99100test('parses line range (from-to) with correct linkLength', () => {101const response = '1. Line 10-15 in `file.ts`, bug, high severity: Multiple lines affected.';102const matches = parseFeedbackResponse(response);103104assert.strictEqual(matches.length, 1);105assert.strictEqual(matches[0].from, 9); // 0-indexed106assert.strictEqual(matches[0].to, 15);107// linkLength = 5 ("Line ") + from.length (2) + to.length (2) + 1 ("-") = 10108assert.strictEqual(matches[0].linkLength, 5 + 2 + 2 + 1);109});110111test('defaults kind to "other" and severity to "unknown" when not specified', () => {112const response = '1. Line 42 in `utils.js`: Minimal comment.';113const matches = parseFeedbackResponse(response);114115assert.strictEqual(matches.length, 1);116assert.strictEqual(matches[0].kind, 'other');117assert.strictEqual(matches[0].severity, 'unknown');118});119120test('parses comment with extra text before "in" keyword', () => {121const response = '1. Line 10 (modified) in `file.ts`, bug: Issue with extra text.';122const matches = parseFeedbackResponse(response);123124assert.strictEqual(matches.length, 1);125assert.strictEqual(matches[0].from, 9);126assert.strictEqual(matches[0].relativeDocumentPath, 'file.ts');127});128129test('parses multiple comments separated by newlines or next numbered item', () => {130const response = `1. Line 10 in \`file.ts\`, bug, high severity: First issue.1311322. Line 20 in \`other.ts\`, performance, low severity: Second issue.1333. Line 30 in \`third.ts\`, bug: Third issue.`;134const matches = parseFeedbackResponse(response);135136assert.strictEqual(matches.length, 3);137assert.strictEqual(matches[0].content, 'First issue.');138assert.strictEqual(matches[1].content, 'Second issue.');139assert.strictEqual(matches[2].content, 'Third issue.');140});141142test('keeps partial comment when dropPartial is false, drops when true', () => {143const partialResponse = '1. Line 10 in `file.ts`, bug: Incomplete';144145// dropPartial = false (default) keeps partial146const matchesKept = parseFeedbackResponse(partialResponse, false);147assert.strictEqual(matchesKept.length, 1);148assert.strictEqual(matchesKept[0].content, 'Incomplete');149150// dropPartial = true drops partial151const matchesDropped = parseFeedbackResponse(partialResponse, true);152assert.strictEqual(matchesDropped.length, 0);153});154155test('keeps first complete comment but drops partial second when dropPartial is true', () => {156const response = `1. Line 10 in \`file.ts\`, bug: First complete.1571582. Line 20 in \`other.ts\`, bug: Partial`;159const matches = parseFeedbackResponse(response, true);160161assert.strictEqual(matches.length, 1);162assert.strictEqual(matches[0].content, 'First complete.');163});164165test('removes trailing complete code block', () => {166const response = `1. Line 33 in \`file.ts\`, readability, low severity: The lambda function could be extracted.167\`\`\`typescript168const extracted = () => doSomething();169\`\`\``;170const matches = parseFeedbackResponse(response);171172assert.strictEqual(matches.length, 1);173assert.strictEqual(matches[0].content, 'The lambda function could be extracted.');174assert.strictEqual(matches[0].content.indexOf('```'), -1);175});176177test('removes broken code block (odd number of markers)', () => {178const response = '1. Line 10 in `file.ts`, bug: Here is some code:\n```typescript\nconst x = 1;';179const matches = parseFeedbackResponse(response);180181assert.strictEqual(matches.length, 1);182assert.strictEqual(matches[0].content, 'Here is some code:');183});184185test('preserves inline code (single backticks)', () => {186const response = '1. Line 10 in `file.ts`, bug: The variable `foo` should be renamed.';187const matches = parseFeedbackResponse(response);188189assert.strictEqual(matches.length, 1);190assert.strictEqual(matches[0].content, 'The variable `foo` should be renamed.');191});192193test('removes trailing ``` via broken block handler when no opening marker exists', () => {194const response = '1. Line 10 in `file.ts`, bug: Some text ending with ```\n\n';195const matches = parseFeedbackResponse(response);196197assert.strictEqual(matches.length, 1);198// Since there's no matching opening ```, the trailing ``` removal fails (i === -1),199// but the broken block handler (odd count) removes it200assert.strictEqual(matches[0].content, 'Some text ending with');201});202203test('preserves complete code block in middle but removes trailing code block', () => {204const response = `1. Line 10 in \`file.ts\`, bug: Example:205\`\`\`typescript206const x = 1;207\`\`\`208Fix:209\`\`\`typescript210const y = 2;211\`\`\``;212const matches = parseFeedbackResponse(response);213214assert.strictEqual(matches.length, 1);215// Should keep the first code block but remove the trailing one216assert.ok(matches[0].content.includes('Example:'));217assert.ok(matches[0].content.includes('```typescript'));218assert.ok(matches[0].content.includes('const x = 1;'));219assert.strictEqual(matches[0].content.includes('const y = 2;'), false);220});221222test('normalizes path separators for subdirectories', () => {223const response = '1. Line 10 in `src/utils/helpers.ts`, bug: Issue.\n\n';224const matches = parseFeedbackResponse(response);225226assert.strictEqual(matches.length, 1);227// On Windows, forward slashes should be converted to backslashes228// On Unix, paths stay with forward slashes229if (path.sep === '\\') {230assert.strictEqual(matches[0].relativeDocumentPath, 'src\\utils\\helpers.ts');231} else {232assert.strictEqual(matches[0].relativeDocumentPath, 'src/utils/helpers.ts');233}234});235236test('returns empty array for empty or invalid response', () => {237assert.strictEqual(parseFeedbackResponse('').length, 0);238assert.strictEqual(parseFeedbackResponse('This is just some text without the expected format.').length, 0);239});240241test('handles line number 1 correctly (0-indexed)', () => {242const response = '1. Line 1 in `file.ts`, bug: First line issue.';243const matches = parseFeedbackResponse(response);244245assert.strictEqual(matches.length, 1);246assert.strictEqual(matches[0].from, 0);247assert.strictEqual(matches[0].to, 1);248});249250test('trims whitespace from content', () => {251const response = '1. Line 10 in `file.ts`, bug: Spaces around content. ';252const matches = parseFeedbackResponse(response);253254assert.strictEqual(matches.length, 1);255assert.strictEqual(matches[0].content, 'Spaces around content.');256});257258test('handles multiline content before next comment', () => {259const response = `1. Line 10 in \`file.ts\`, bug: This is a longer260description that spans261multiple lines.2622632. Line 20 in \`other.ts\`, bug: Next issue.`;264const matches = parseFeedbackResponse(response);265266assert.strictEqual(matches.length, 2);267assert.ok(matches[0].content.includes('longer'));268assert.ok(matches[0].content.includes('multiple lines.'));269});270271test('handles multi-digit item numbers for linkOffset calculation', () => {272const response = '10. Line 5 in `file.ts`, bug: Issue.\n\n';273const matches = parseFeedbackResponse(response);274275assert.strictEqual(matches.length, 1);276// linkOffset = match.index + num.length + 2 = 0 + 2 + 2 = 4277assert.strictEqual(matches[0].linkOffset, 4);278});279});280281describe('parseReviewComments', () => {282283test('parses valid comment and creates ReviewComment', () => {284const uri = Uri.file('/test/file.ts');285const content = 'line 0\nline 1\nline 2\nline 3\nline 4';286const snapshot = createTestSnapshot(uri, content);287const input: CurrentChangeInput[] = [{288document: snapshot,289relativeDocumentPath: 'file.ts',290change: {291repository: {} as any,292uri,293hunks: [{ range: new Range(0, 0, 4, 6), text: content }]294}295}];296const request = createReviewRequest();297const message = '1. Line 2 in `file.ts`, bug, high severity: This is a bug.\n\n';298299const comments = parseReviewComments(request, input, message);300301assert.strictEqual(comments.length, 1);302assert.strictEqual(comments[0].kind, 'bug');303assert.strictEqual(comments[0].severity, 'high');304assert.strictEqual(typeof comments[0].body === 'string' ? comments[0].body : comments[0].body.value, 'This is a bug.');305assert.strictEqual(comments[0].uri, uri);306assert.strictEqual(comments[0].languageId, 'typescript');307assert.strictEqual(comments[0].originalIndex, 0);308assert.strictEqual(comments[0].actionCount, 0);309assert.strictEqual(comments[0].request, request);310});311312test('parses multiple comments from same input', () => {313const uri = Uri.file('/test/file.ts');314const content = 'line 0\nline 1\nline 2\nline 3\nline 4\nline 5';315const snapshot = createTestSnapshot(uri, content);316const input: CurrentChangeInput[] = [{317document: snapshot,318relativeDocumentPath: 'file.ts',319change: {320repository: {} as any,321uri,322hunks: [{ range: new Range(0, 0, 5, 6), text: content }]323}324}];325const request = createReviewRequest();326const message = `1. Line 2 in \`file.ts\`, bug: First issue.3273282. Line 4 in \`file.ts\`, performance: Second issue.329330`;331332const comments = parseReviewComments(request, input, message);333334assert.strictEqual(comments.length, 2);335assert.strictEqual(typeof comments[0].body === 'string' ? comments[0].body : comments[0].body.value, 'First issue.');336assert.strictEqual(comments[0].originalIndex, 0);337assert.strictEqual(typeof comments[1].body === 'string' ? comments[1].body : comments[1].body.value, 'Second issue.');338assert.strictEqual(comments[1].originalIndex, 1);339});340341test('filters out unknown kind', () => {342const uri = Uri.file('/test/file.ts');343const content = 'line 0\nline 1\nline 2';344const snapshot = createTestSnapshot(uri, content);345const input: CurrentChangeInput[] = [{346document: snapshot,347relativeDocumentPath: 'file.ts',348change: {349repository: {} as any,350uri,351hunks: [{ range: new Range(0, 0, 2, 6), text: content }]352}353}];354const request = createReviewRequest();355const message = '1. Line 2 in `file.ts`, unknownKind: Should be filtered.\n\n';356357const comments = parseReviewComments(request, input, message);358359assert.strictEqual(comments.length, 0);360});361362test('accepts all known kinds', () => {363const knownKinds = ['bug', 'performance', 'consistency', 'documentation', 'naming', 'readability', 'style', 'other'];364const uri = Uri.file('/test/file.ts');365const content = Array.from({ length: 20 }, (_, i) => `line ${i}`).join('\n');366const snapshot = createTestSnapshot(uri, content);367const input: CurrentChangeInput[] = [{368document: snapshot,369relativeDocumentPath: 'file.ts',370change: {371repository: {} as any,372uri,373hunks: [{ range: new Range(0, 0, 19, 7), text: content }]374}375}];376const request = createReviewRequest();377const message = knownKinds.map((kind, i) => `${i + 1}. Line ${i + 1} in \`file.ts\`, ${kind}: Issue ${i + 1}.`).join('\n\n') + '\n\n';378379const comments = parseReviewComments(request, input, message);380381assert.strictEqual(comments.length, knownKinds.length);382knownKinds.forEach((kind, i) => {383assert.strictEqual(comments[i].kind, kind);384});385});386387test('skips comment when relativeDocumentPath does not match any input', () => {388const uri = Uri.file('/test/file.ts');389const content = 'line 0\nline 1';390const snapshot = createTestSnapshot(uri, content);391const input: CurrentChangeInput[] = [{392document: snapshot,393relativeDocumentPath: 'different.ts',394change: {395repository: {} as any,396uri,397hunks: [{ range: new Range(0, 0, 1, 6), text: content }]398}399}];400const request = createReviewRequest();401const message = '1. Line 1 in `file.ts`, bug: Should be skipped.\n\n';402403const comments = parseReviewComments(request, input, message);404405assert.strictEqual(comments.length, 0);406});407408test('matches correct input from multiple inputs', () => {409const uri1 = Uri.file('/test/first.ts');410const uri2 = Uri.file('/test/second.ts');411const content = 'line 0\nline 1';412const snapshot1 = createTestSnapshot(uri1, content);413const snapshot2 = createTestSnapshot(uri2, content);414const input: CurrentChangeInput[] = [415{416document: snapshot1,417relativeDocumentPath: 'first.ts',418change: {419repository: {} as any,420uri: uri1,421hunks: [{ range: new Range(0, 0, 1, 6), text: content }]422}423},424{425document: snapshot2,426relativeDocumentPath: 'second.ts',427change: {428repository: {} as any,429uri: uri2,430hunks: [{ range: new Range(0, 0, 1, 6), text: content }]431}432}433];434const request = createReviewRequest();435const message = '1. Line 1 in `second.ts`, bug: Found in second file.\n\n';436437const comments = parseReviewComments(request, input, message);438439assert.strictEqual(comments.length, 1);440assert.strictEqual(comments[0].uri, uri2);441});442443test('uses line 0 correctly when Line 1 is specified (0-indexed)', () => {444const uri = Uri.file('/test/file.ts');445const content = ' indented line\nline 1';446const snapshot = createTestSnapshot(uri, content);447const input: CurrentChangeInput[] = [{448document: snapshot,449relativeDocumentPath: 'file.ts',450change: {451repository: {} as any,452uri,453hunks: [{ range: new Range(0, 0, 1, 6), text: content }]454}455}];456const request = createReviewRequest();457// Line 1 in the message becomes 0 after 0-indexing458const message = '1. Line 1 in `file.ts`, bug: First line.\n\n';459460const comments = parseReviewComments(request, input, message);461462assert.strictEqual(comments.length, 1);463// Range should start at line 0464assert.strictEqual(comments[0].range.start.line, 0);465// firstNonWhitespaceCharacterIndex for " indented line" is 2466assert.strictEqual(comments[0].range.start.character, 2);467});468469test('clamps line number exceeding lineCount', () => {470const uri = Uri.file('/test/file.ts');471const content = 'line 0\nline 1\nlast line';472const snapshot = createTestSnapshot(uri, content);473const input: CurrentChangeInput[] = [{474document: snapshot,475relativeDocumentPath: 'file.ts',476change: {477repository: {} as any,478uri,479hunks: [{ range: new Range(0, 0, 2, 9), text: content }]480}481}];482const request = createReviewRequest();483// Line 100 is way beyond lineCount of 3484const message = '1. Line 1-100 in `file.ts`, bug: Should clamp to end.\n\n';485486const comments = parseReviewComments(request, input, message);487488assert.strictEqual(comments.length, 1);489// End line should be clamped to lineCount-1 = 2490assert.strictEqual(comments[0].range.end.line, 2);491});492493test('filters out comment outside change hunk range', () => {494const uri = Uri.file('/test/file.ts');495const content = 'line 0\nline 1\nline 2\nline 3\nline 4';496const snapshot = createTestSnapshot(uri, content);497const input: CurrentChangeInput[] = [{498document: snapshot,499relativeDocumentPath: 'file.ts',500change: {501repository: {} as any,502uri,503// Change only affects lines 0-1504hunks: [{ range: new Range(0, 0, 1, 6), text: 'line 0\nline 1' }]505}506}];507const request = createReviewRequest();508// Comment on line 4 which is outside the hunk range509const message = '1. Line 5 in `file.ts`, bug: Outside hunk range.\n\n';510511const comments = parseReviewComments(request, input, message);512513assert.strictEqual(comments.length, 0);514});515516test('uses selection range for filtering when selection is provided', () => {517const uri = Uri.file('/test/file.ts');518const content = 'line 0\nline 1\nline 2\nline 3\nline 4';519const snapshot = createTestSnapshot(uri, content);520const selection = new Range(2, 0, 3, 6);521const input: CurrentChangeInput[] = [{522document: snapshot,523relativeDocumentPath: 'file.ts',524selection525}];526const request = createReviewRequest({ inputType: 'selection' });527528// Comment on line 1 (outside selection)529const messageOutside = '1. Line 1 in `file.ts`, bug: Outside selection.\n\n';530const commentsOutside = parseReviewComments(request, input, messageOutside);531assert.strictEqual(commentsOutside.length, 0);532533// Comment on line 3 (inside selection)534const messageInside = '1. Line 3 in `file.ts`, bug: Inside selection.\n\n';535const commentsInside = parseReviewComments(request, input, messageInside);536assert.strictEqual(commentsInside.length, 1);537});538539test('includes comment when no filterRanges (no selection or change)', () => {540const uri = Uri.file('/test/file.ts');541const content = 'line 0\nline 1';542const snapshot = createTestSnapshot(uri, content);543const input: CurrentChangeInput[] = [{544document: snapshot,545relativeDocumentPath: 'file.ts'546// No selection or change547}];548const request = createReviewRequest();549const message = '1. Line 1 in `file.ts`, bug: No filter ranges.\n\n';550551const comments = parseReviewComments(request, input, message);552553assert.strictEqual(comments.length, 1);554});555556test('includes comment when intersecting any of multiple hunks', () => {557const uri = Uri.file('/test/file.ts');558const content = 'line 0\nline 1\nline 2\nline 3\nline 4\nline 5';559const snapshot = createTestSnapshot(uri, content);560const input: CurrentChangeInput[] = [{561document: snapshot,562relativeDocumentPath: 'file.ts',563change: {564repository: {} as any,565uri,566hunks: [567{ range: new Range(0, 0, 1, 6), text: 'line 0\nline 1' },568{ range: new Range(4, 0, 5, 6), text: 'line 4\nline 5' }569]570}571}];572const request = createReviewRequest();573574// Comment on line 5 intersects second hunk575const message = '1. Line 5 in `file.ts`, bug: In second hunk.\n\n';576const comments = parseReviewComments(request, input, message);577578assert.strictEqual(comments.length, 1);579});580581test('passes dropPartial to parseFeedbackResponse', () => {582const uri = Uri.file('/test/file.ts');583const content = 'line 0\nline 1';584const snapshot = createTestSnapshot(uri, content);585const input: CurrentChangeInput[] = [{586document: snapshot,587relativeDocumentPath: 'file.ts',588change: {589repository: {} as any,590uri,591hunks: [{ range: new Range(0, 0, 1, 6), text: content }]592}593}];594const request = createReviewRequest();595// Partial response (no terminating newline)596const partialMessage = '1. Line 1 in `file.ts`, bug: Partial';597598// dropPartial = false keeps the partial comment599const commentsKept = parseReviewComments(request, input, partialMessage, false);600assert.strictEqual(commentsKept.length, 1);601602// dropPartial = true drops the partial comment603const commentsDropped = parseReviewComments(request, input, partialMessage, true);604assert.strictEqual(commentsDropped.length, 0);605});606607test('returns empty array for empty message', () => {608const uri = Uri.file('/test/file.ts');609const content = 'line 0';610const snapshot = createTestSnapshot(uri, content);611const input: CurrentChangeInput[] = [{612document: snapshot,613relativeDocumentPath: 'file.ts',614change: {615repository: {} as any,616uri,617hunks: [{ range: new Range(0, 0, 0, 6), text: content }]618}619}];620const request = createReviewRequest();621622const comments = parseReviewComments(request, input, '');623624assert.strictEqual(comments.length, 0);625});626627test('returns empty array when no inputs provided', () => {628const request = createReviewRequest();629const message = '1. Line 1 in `file.ts`, bug: No inputs.\n\n';630631const comments = parseReviewComments(request, [], message);632633assert.strictEqual(comments.length, 0);634});635636test('sets correct range with firstNonWhitespaceCharacterIndex', () => {637const uri = Uri.file('/test/file.ts');638const content = ' indented content here';639const snapshot = createTestSnapshot(uri, content);640const input: CurrentChangeInput[] = [{641document: snapshot,642relativeDocumentPath: 'file.ts',643change: {644repository: {} as any,645uri,646hunks: [{ range: new Range(0, 0, 0, 25), text: content }]647}648}];649const request = createReviewRequest();650const message = '1. Line 1 in `file.ts`, bug: Indented line.\n\n';651652const comments = parseReviewComments(request, input, message);653654assert.strictEqual(comments.length, 1);655// Start character should be firstNonWhitespaceCharacterIndex (4 spaces)656assert.strictEqual(comments[0].range.start.character, 4);657// End character should be lastNonWhitespaceCharacterIndex (25 - no trailing whitespace)658assert.strictEqual(comments[0].range.end.character, 25);659});660661test('handles line range spanning multiple lines', () => {662const uri = Uri.file('/test/file.ts');663const content = ' line 0\nline 1\n line 2 with trailing ';664const snapshot = createTestSnapshot(uri, content);665const input: CurrentChangeInput[] = [{666document: snapshot,667relativeDocumentPath: 'file.ts',668change: {669repository: {} as any,670uri,671hunks: [{ range: new Range(0, 0, 2, 27), text: content }]672}673}];674const request = createReviewRequest();675const message = '1. Line 1-3 in `file.ts`, bug: Multi-line issue.\n\n';676677const comments = parseReviewComments(request, input, message);678679assert.strictEqual(comments.length, 1);680// Start: line 0, firstNonWhitespaceCharacterIndex = 2681assert.strictEqual(comments[0].range.start.line, 0);682assert.strictEqual(comments[0].range.start.character, 2);683// End: line 2, lastNonWhitespaceCharacterIndex for " line 2 with trailing " is 22 (trimEnd removes trailing spaces)684assert.strictEqual(comments[0].range.end.line, 2);685assert.strictEqual(comments[0].range.end.character, 22);686});687688test('preserves document reference in comment', () => {689const uri = Uri.file('/test/file.ts');690const content = 'line 0';691const snapshot = createTestSnapshot(uri, content);692const input: CurrentChangeInput[] = [{693document: snapshot,694relativeDocumentPath: 'file.ts',695change: {696repository: {} as any,697uri,698hunks: [{ range: new Range(0, 0, 0, 6), text: content }]699}700}];701const request = createReviewRequest();702const message = '1. Line 1 in `file.ts`, bug: Check document.\n\n';703704const comments = parseReviewComments(request, input, message);705706assert.strictEqual(comments.length, 1);707assert.strictEqual(comments[0].document, snapshot);708});709});710711class MockIgnoreService extends NullIgnoreService {712override get isEnabled(): boolean { return true; }713override get isRegexExclusionsEnabled(): boolean { return true; }714715private _ignoredUris = new Set<string>();716private _alwaysIgnore = false;717718override async isCopilotIgnored(file: Uri): Promise<boolean> {719if (this._alwaysIgnore) {720return true;721}722return this._ignoredUris.has(file.toString());723}724725setAlwaysIgnore(): void {726this._alwaysIgnore = true;727}728729setIgnoredUris(uris: Uri[]): void {730this._ignoredUris = new Set(uris.map(u => u.toString()));731}732733reset(): void {734this._alwaysIgnore = false;735this._ignoredUris.clear();736}737}738739class MockChatEndpoint {740model = 'gpt-4.1-test';741family = 'gpt-4.1';742name = 'Test Endpoint';743maxOutputTokens = 8000;744modelMaxPromptTokens = 128000;745supportsToolCalls = true;746supportsVision = true;747supportsPrediction = true;748showInModelPicker = true;749isDefault = true;750isFallback = false;751policy: 'enabled' | { terms: string } = 'enabled';752urlOrRequestMetadata = 'https://test.com';753version = '1.0';754tokenizer = 'o200k_base';755756private _response: ChatResponse = { type: ChatFetchResponseType.Success, value: '', requestId: 'test-request-id', serverRequestId: undefined, usage: undefined, resolvedModel: 'gpt-4.1-test' };757758setResponse(response: ChatResponse): void {759this._response = response;760}761762async makeChatRequest(763_debugName: string,764_messages: Raw.ChatMessage[],765finishedCb: ((text: string) => Promise<void>) | undefined,766_token: CancellationToken,767): Promise<ChatResponse> {768if (this._response.type === ChatFetchResponseType.Success && finishedCb) {769await finishedCb(this._response.value);770}771return this._response;772}773774acquireTokenizer(): any {775return {776tokenize: (text: string) => ({ bpe: text.split(' ').map((_, i) => i), text }),777tokenLength: (text: string) => Math.ceil(text.length / 4),778encode: (text: string) => text.split(' ').map((_, i) => i),779decode: (tokens: number[]) => tokens.join(' '),780};781}782}783784class MockEndpointProvider implements IEndpointProvider {785declare readonly _serviceBrand: undefined;786readonly onDidModelsRefresh = Event.None;787788private _endpoint = new MockChatEndpoint();789790get mockEndpoint(): MockChatEndpoint {791return this._endpoint;792}793794async getChatEndpoint(): Promise<IChatEndpoint> {795return this._endpoint as unknown as IChatEndpoint;796}797798async getEmbeddingsEndpoint(): Promise<any> {799throw new Error('Not implemented');800}801802async getAllChatEndpoints(): Promise<IChatEndpoint[]> {803return [this._endpoint as unknown as IChatEndpoint];804}805806async getAllCompletionModels(): Promise<any[]> {807return [];808}809}810811describe('FeedbackGenerator.generateComments', () => {812let disposables: DisposableStore;813let mockIgnoreService: MockIgnoreService;814let mockEndpointProvider: MockEndpointProvider;815let feedbackGenerator: FeedbackGenerator;816let instantiationService: IInstantiationService;817818beforeEach(() => {819disposables = new DisposableStore();820mockIgnoreService = new MockIgnoreService();821mockEndpointProvider = new MockEndpointProvider();822823const serviceCollection = disposables.add(createExtensionUnitTestingServices());824serviceCollection.define(IIgnoreService, mockIgnoreService);825serviceCollection.define(IEndpointProvider, mockEndpointProvider);826serviceCollection.define(ITelemetryService, new NullTelemetryService());827instantiationService = serviceCollection.createTestingAccessor().get(IInstantiationService);828feedbackGenerator = instantiationService.createInstance(FeedbackGenerator);829});830831afterEach(() => {832disposables.dispose();833});834835function createInput(836uri: Uri,837content: string,838relativeDocumentPath: string,839options?: { selection?: Range; hunks?: { range: Range; text: string }[] }840): CurrentChangeInput {841const snapshot = createTestSnapshot(uri, content);842const input: CurrentChangeInput = {843document: snapshot,844relativeDocumentPath,845};846if (options?.selection) {847input.selection = options.selection;848}849if (options?.hunks) {850input.change = {851repository: {} as any,852uri,853hunks: options.hunks,854};855}856return input;857}858859test('returns success with comments when endpoint returns valid response', async () => {860const uri = Uri.file('/test/file.ts');861const content = 'line 0\nline 1\nline 2\nline 3\nline 4';862const input = [createInput(uri, content, 'file.ts', {863hunks: [{ range: new Range(0, 0, 4, 6), text: content }]864})];865866mockEndpointProvider.mockEndpoint.setResponse({867type: ChatFetchResponseType.Success,868value: '1. Line 2 in `file.ts`, bug, high severity: This is a bug.\n\n',869requestId: 'test-request-id',870serverRequestId: undefined,871usage: undefined,872resolvedModel: 'gpt-4.1-test'873});874875const result = await feedbackGenerator.generateComments(input, CancellationToken.None);876877assert.strictEqual(result.type, 'success');878if (result.type === 'success') {879assert.strictEqual(result.comments.length, 1);880assert.strictEqual(result.comments[0].kind, 'bug');881assert.strictEqual(result.comments[0].severity, 'high');882}883});884885test('returns success with empty comments when endpoint returns no comments', async () => {886const uri = Uri.file('/test/file.ts');887const content = 'line 0\nline 1';888const input = [createInput(uri, content, 'file.ts', {889hunks: [{ range: new Range(0, 0, 1, 6), text: content }]890})];891892mockEndpointProvider.mockEndpoint.setResponse({893type: ChatFetchResponseType.Success,894value: 'No issues found in this code.',895requestId: 'test-request-id',896serverRequestId: undefined,897usage: undefined,898resolvedModel: 'gpt-4.1-test'899});900901const result = await feedbackGenerator.generateComments(input, CancellationToken.None);902903assert.strictEqual(result.type, 'success');904if (result.type === 'success') {905assert.strictEqual(result.comments.length, 0);906}907});908909test('returns multiple comments from single response', async () => {910const uri = Uri.file('/test/file.ts');911const content = 'line 0\nline 1\nline 2\nline 3\nline 4\nline 5';912const input = [createInput(uri, content, 'file.ts', {913hunks: [{ range: new Range(0, 0, 5, 6), text: content }]914})];915916mockEndpointProvider.mockEndpoint.setResponse({917type: ChatFetchResponseType.Success,918value: `1. Line 2 in \`file.ts\`, bug, high severity: First bug.9199202. Line 4 in \`file.ts\`, performance, medium severity: Performance issue.921922`,923requestId: 'test-request-id',924serverRequestId: undefined,925usage: undefined,926resolvedModel: 'gpt-4.1-test'927});928929const result = await feedbackGenerator.generateComments(input, CancellationToken.None);930931assert.strictEqual(result.type, 'success');932if (result.type === 'success') {933assert.strictEqual(result.comments.length, 2);934assert.strictEqual(result.comments[0].kind, 'bug');935assert.strictEqual(result.comments[1].kind, 'performance');936}937});938939test('returns error when all inputs are ignored', async () => {940const uri = Uri.file('/test/file.ts');941const content = 'line 0\nline 1';942const input = [createInput(uri, content, 'file.ts', {943hunks: [{ range: new Range(0, 0, 1, 6), text: content }]944})];945946mockIgnoreService.setAlwaysIgnore();947948const result = await feedbackGenerator.generateComments(input, CancellationToken.None);949950assert.strictEqual(result.type, 'error');951if (result.type === 'error') {952assert.strictEqual(result.severity, 'info');953assert.ok(result.reason.includes('ignored'));954}955});956957test('filters out ignored documents but processes non-ignored ones', async () => {958const uri1 = Uri.file('/test/ignored.ts');959const uri2 = Uri.file('/test/allowed.ts');960const content = 'line 0\nline 1';961const input = [962createInput(uri1, content, 'ignored.ts', {963hunks: [{ range: new Range(0, 0, 1, 6), text: content }]964}),965createInput(uri2, content, 'allowed.ts', {966hunks: [{ range: new Range(0, 0, 1, 6), text: content }]967})968];969970mockIgnoreService.setIgnoredUris([uri1]);971972mockEndpointProvider.mockEndpoint.setResponse({973type: ChatFetchResponseType.Success,974value: '1. Line 1 in `allowed.ts`, bug: Issue in allowed file.\n\n',975requestId: 'test-request-id',976serverRequestId: undefined,977usage: undefined,978resolvedModel: 'gpt-4.1-test'979});980981const result = await feedbackGenerator.generateComments(input, CancellationToken.None);982983assert.strictEqual(result.type, 'success');984if (result.type === 'success') {985assert.strictEqual(result.comments.length, 1);986assert.strictEqual(result.comments[0].uri.toString(), uri2.toString());987}988});989990test('returns cancelled when token is cancelled before request', async () => {991const uri = Uri.file('/test/file.ts');992const content = 'line 0\nline 1';993const input = [createInput(uri, content, 'file.ts', {994hunks: [{ range: new Range(0, 0, 1, 6), text: content }]995})];996997const tokenSource = new CancellationTokenSource();998tokenSource.cancel();9991000const result = await feedbackGenerator.generateComments(input, tokenSource.token);10011002assert.strictEqual(result.type, 'cancelled');1003});10041005test('returns error when endpoint returns error', async () => {1006const uri = Uri.file('/test/file.ts');1007const content = 'line 0\nline 1';1008const input = [createInput(uri, content, 'file.ts', {1009hunks: [{ range: new Range(0, 0, 1, 6), text: content }]1010})];10111012mockEndpointProvider.mockEndpoint.setResponse({1013type: ChatFetchResponseType.Failed,1014reason: 'API error',1015requestId: 'test-request-id',1016serverRequestId: undefined1017});10181019const result = await feedbackGenerator.generateComments(input, CancellationToken.None);10201021assert.strictEqual(result.type, 'error');1022if (result.type === 'error') {1023assert.strictEqual(result.reason, 'API error');1024}1025});10261027test('reports progress when progress callback is provided', async () => {1028const uri = Uri.file('/test/file.ts');1029const content = 'line 0\nline 1\nline 2\nline 3\nline 4';1030const input = [createInput(uri, content, 'file.ts', {1031hunks: [{ range: new Range(0, 0, 4, 6), text: content }]1032})];10331034mockEndpointProvider.mockEndpoint.setResponse({1035type: ChatFetchResponseType.Success,1036value: '1. Line 2 in `file.ts`, bug, high severity: This is a bug.\n\n',1037requestId: 'test-request-id',1038serverRequestId: undefined,1039usage: undefined,1040resolvedModel: 'gpt-4.1-test'1041});10421043const reportedComments: ReviewComment[][] = [];1044const progress = {1045report: (comments: ReviewComment[]) => {1046reportedComments.push(comments);1047}1048};10491050await feedbackGenerator.generateComments(input, CancellationToken.None, progress);10511052// Progress should have been reported at least once1053assert.ok(reportedComments.length > 0);1054});10551056test('handles selection input correctly', async () => {1057const uri = Uri.file('/test/file.ts');1058const content = 'line 0\nline 1\nline 2\nline 3\nline 4';1059const input = [createInput(uri, content, 'file.ts', {1060selection: new Range(1, 0, 3, 6)1061})];10621063mockEndpointProvider.mockEndpoint.setResponse({1064type: ChatFetchResponseType.Success,1065value: '1. Line 2 in `file.ts`, bug: Selection issue.\n\n',1066requestId: 'test-request-id',1067serverRequestId: undefined,1068usage: undefined,1069resolvedModel: 'gpt-4.1-test'1070});10711072const result = await feedbackGenerator.generateComments(input, CancellationToken.None);10731074assert.strictEqual(result.type, 'success');1075if (result.type === 'success') {1076assert.strictEqual(result.comments.length, 1);1077}1078});10791080test('handles multiple files correctly', async () => {1081const uri1 = Uri.file('/test/first.ts');1082const uri2 = Uri.file('/test/second.ts');1083const content = 'line 0\nline 1';1084const input = [1085createInput(uri1, content, 'first.ts', {1086hunks: [{ range: new Range(0, 0, 1, 6), text: content }]1087}),1088createInput(uri2, content, 'second.ts', {1089hunks: [{ range: new Range(0, 0, 1, 6), text: content }]1090})1091];10921093mockEndpointProvider.mockEndpoint.setResponse({1094type: ChatFetchResponseType.Success,1095value: `1. Line 1 in \`first.ts\`, bug: Issue in first file.109610972. Line 1 in \`second.ts\`, performance: Issue in second file.10981099`,1100requestId: 'test-request-id',1101serverRequestId: undefined,1102usage: undefined,1103resolvedModel: 'gpt-4.1-test'1104});11051106const result = await feedbackGenerator.generateComments(input, CancellationToken.None);11071108assert.strictEqual(result.type, 'success');1109if (result.type === 'success') {1110assert.strictEqual(result.comments.length, 2);1111assert.strictEqual(result.comments[0].uri.toString(), uri1.toString());1112assert.strictEqual(result.comments[1].uri.toString(), uri2.toString());1113}1114});11151116test('handles empty input array', async () => {1117const result = await feedbackGenerator.generateComments([], CancellationToken.None);11181119assert.strictEqual(result.type, 'error');1120if (result.type === 'error') {1121assert.strictEqual(result.severity, 'info');1122}1123});11241125test('handles input with no changes or selection', async () => {1126const uri = Uri.file('/test/file.ts');1127const content = 'line 0\nline 1';1128const input = [createInput(uri, content, 'file.ts')];11291130mockEndpointProvider.mockEndpoint.setResponse({1131type: ChatFetchResponseType.Success,1132value: '1. Line 1 in `file.ts`, bug: Issue.\n\n',1133requestId: 'test-request-id',1134serverRequestId: undefined,1135usage: undefined,1136resolvedModel: 'gpt-4.1-test'1137});11381139const result = await feedbackGenerator.generateComments(input, CancellationToken.None);11401141assert.strictEqual(result.type, 'success');1142});11431144test('returns error when prompts exceed maxPrompts limit', async () => {1145// Create many inputs that will each become a separate prompt after splitting1146const inputs: CurrentChangeInput[] = [];1147for (let i = 0; i < 15; i++) {1148const uri = Uri.file(`/test/file${i}.ts`);1149const content = 'line 0\nline 1';1150inputs.push(createInput(uri, content, `file${i}.ts`, {1151hunks: [{ range: new Range(0, 0, 1, 6), text: content }]1152}));1153}11541155// Mock the endpoint to track calls1156let callCount = 0;1157const originalMakeChatRequest = mockEndpointProvider.mockEndpoint.makeChatRequest.bind(mockEndpointProvider.mockEndpoint);1158mockEndpointProvider.mockEndpoint.makeChatRequest = async (debugName, messages, finishedCb, token) => {1159callCount++;1160return originalMakeChatRequest(debugName, messages, finishedCb, token);1161};11621163// Since we can't easily mock the PromptRenderer to throw split_input,1164// we test the error message when inputType is 'selection' vs 'change'1165// The actual maxPrompts > 10 is hard to trigger without mocking PromptRenderer1166// This test documents the expected behavior1167const result = await feedbackGenerator.generateComments(inputs, CancellationToken.None);11681169// With 15 files that don't cause split_input, they should be processed1170// If the batch was split enough times (>10 prompts), we'd get an error1171assert.ok(result.type === 'success' || result.type === 'error');1172});1173});11741175class MockLogService extends TestLogService {1176readonly debugMessages: string[] = [];1177readonly warnMessages: string[] = [];11781179override debug(message: string): void { this.debugMessages.push(message); }1180override warn(message: string): void { this.warnMessages.push(message); }11811182reset(): void {1183this.debugMessages.length = 0;1184this.warnMessages.length = 0;1185}1186}11871188interface TelemetryCall {1189eventName: string;1190properties?: TelemetryEventProperties;1191measurements?: TelemetryEventMeasurements;1192}11931194class MockTelemetryService extends NullTelemetryService {1195readonly msftEvents: TelemetryCall[] = [];1196readonly internalMsftEvents: TelemetryCall[] = [];11971198override sendMSFTTelemetryEvent(eventName: string, properties?: TelemetryEventProperties, measurements?: TelemetryEventMeasurements): void {1199this.msftEvents.push({ eventName, properties, measurements });1200}12011202override sendInternalMSFTTelemetryEvent(eventName: string, properties?: TelemetryEventProperties, measurements?: TelemetryEventMeasurements): void {1203this.internalMsftEvents.push({ eventName, properties, measurements });1204}12051206reset(): void {1207this.msftEvents.length = 0;1208this.internalMsftEvents.length = 0;1209}1210}12111212describe('sendReviewActionTelemetry', () => {1213let mockLogService: MockLogService;1214let mockTelemetryService: MockTelemetryService;1215let mockInstantiationService: IInstantiationService;1216let disposables: DisposableStore;12171218function createTestReviewComment(overrides?: Partial<ReviewComment>): ReviewComment {1219const uri = Uri.file('/test/file.ts');1220const content = 'line 0\nline 1\nline 2';1221const docData = createTextDocumentData(uri, content, 'typescript');1222const snapshot = TextDocumentSnapshot.create(docData.document);12231224return {1225request: {1226source: 'vscodeCopilotChat',1227promptCount: 1,1228messageId: 'test-message-id',1229inputType: 'change',1230inputRanges: [{ uri, ranges: [new Range(0, 0, 2, 6)] }],1231},1232document: snapshot,1233uri,1234languageId: 'typescript',1235range: new Range(1, 0, 1, 6),1236body: new MarkdownString('Test comment body'),1237kind: 'bug',1238severity: 'high',1239originalIndex: 0,1240actionCount: 0,1241...overrides,1242};1243}12441245beforeEach(() => {1246disposables = new DisposableStore();1247mockLogService = new MockLogService();1248mockTelemetryService = new MockTelemetryService();12491250const serviceCollection = disposables.add(createExtensionUnitTestingServices());1251mockInstantiationService = serviceCollection.createTestingAccessor().get(IInstantiationService);1252});12531254afterEach(() => {1255disposables.dispose();1256});12571258test.each([1259['helpful', 5],1260['unhelpful', 3],1261] as const)('sends review.comment.vote telemetry for %s action', (action, totalComments) => {1262const comment = createTestReviewComment();12631264sendReviewActionTelemetry(comment, totalComments, action, mockLogService, mockTelemetryService, mockInstantiationService);12651266assert.strictEqual(mockLogService.debugMessages.length, 1);1267assert.ok(mockLogService.debugMessages[0].includes('user feedback received'));12681269assert.strictEqual(mockTelemetryService.msftEvents.length, 1);1270assert.strictEqual(mockTelemetryService.msftEvents[0].eventName, 'review.comment.vote');1271assert.strictEqual(mockTelemetryService.msftEvents[0].properties?.userAction, action);1272assert.strictEqual(mockTelemetryService.msftEvents[0].properties?.commentType, 'bug');1273assert.strictEqual(mockTelemetryService.msftEvents[0].measurements?.totalComments, totalComments);12741275assert.strictEqual(mockTelemetryService.internalMsftEvents.length, 1);1276assert.strictEqual(mockTelemetryService.internalMsftEvents[0].eventName, 'review.comment.vote');1277});12781279test('does not increment actionCount for vote actions', () => {1280const comment = createTestReviewComment({ actionCount: 2 });12811282sendReviewActionTelemetry(comment, 1, 'helpful', mockLogService, mockTelemetryService, mockInstantiationService);12831284assert.strictEqual(comment.actionCount, 2);1285});12861287test('sends review.comment.action telemetry for apply action', () => {1288const comment = createTestReviewComment();12891290sendReviewActionTelemetry(comment, 2, 'apply', mockLogService, mockTelemetryService, mockInstantiationService);12911292assert.strictEqual(mockTelemetryService.msftEvents.length, 1);1293assert.strictEqual(mockTelemetryService.msftEvents[0].eventName, 'review.comment.action');1294assert.strictEqual(mockTelemetryService.msftEvents[0].properties?.userAction, 'apply');1295});12961297test('increments actionCount for non-vote actions', () => {1298const comment = createTestReviewComment({ actionCount: 0 });12991300sendReviewActionTelemetry(comment, 1, 'apply', mockLogService, mockTelemetryService, mockInstantiationService);13011302assert.strictEqual(comment.actionCount, 1);1303});13041305test('increments actionCount multiple times for multiple actions', () => {1306const comment = createTestReviewComment({ actionCount: 0 });13071308sendReviewActionTelemetry(comment, 1, 'apply', mockLogService, mockTelemetryService, mockInstantiationService);1309sendReviewActionTelemetry(comment, 1, 'discard', mockLogService, mockTelemetryService, mockInstantiationService);13101311assert.strictEqual(comment.actionCount, 2);1312});13131314test('returns early and warns when no comments provided', () => {1315sendReviewActionTelemetry([], 0, 'helpful', mockLogService, mockTelemetryService, mockInstantiationService);13161317assert.strictEqual(mockLogService.warnMessages.length, 1);1318assert.ok(mockLogService.warnMessages[0].includes('No review comment found'));1319assert.strictEqual(mockTelemetryService.msftEvents.length, 0);1320});13211322test('handles single comment (not array)', () => {1323const comment = createTestReviewComment();13241325sendReviewActionTelemetry(comment, 1, 'helpful', mockLogService, mockTelemetryService, mockInstantiationService);13261327assert.strictEqual(mockTelemetryService.msftEvents.length, 1);1328assert.strictEqual(mockTelemetryService.msftEvents[0].measurements?.comments, 1);1329});13301331test('handles array of comments', () => {1332const comment1 = createTestReviewComment({ originalIndex: 0 });1333const comment2 = createTestReviewComment({ originalIndex: 1, body: new MarkdownString('Second comment') });13341335sendReviewActionTelemetry([comment1, comment2], 3, 'helpful', mockLogService, mockTelemetryService, mockInstantiationService);13361337assert.strictEqual(mockTelemetryService.msftEvents.length, 1);1338assert.strictEqual(mockTelemetryService.msftEvents[0].measurements?.comments, 2);1339});13401341test('uses unknown for unrecognized comment kind', () => {1342const comment = createTestReviewComment({ kind: 'unknownKind' });13431344sendReviewActionTelemetry(comment, 1, 'helpful', mockLogService, mockTelemetryService, mockInstantiationService);13451346assert.strictEqual(mockTelemetryService.msftEvents[0].properties?.commentType, 'unknown');1347});13481349test('calculates commentLength correctly for MarkdownString body', () => {1350const comment = createTestReviewComment({ body: new MarkdownString('Hello world') });13511352sendReviewActionTelemetry(comment, 1, 'helpful', mockLogService, mockTelemetryService, mockInstantiationService);13531354assert.strictEqual(mockTelemetryService.msftEvents[0].measurements?.commentLength, 11);1355});13561357test('calculates commentLength correctly for string body', () => {1358const comment = createTestReviewComment({ body: 'Plain text body' });13591360sendReviewActionTelemetry(comment, 1, 'helpful', mockLogService, mockTelemetryService, mockInstantiationService);13611362assert.strictEqual(mockTelemetryService.msftEvents[0].measurements?.commentLength, 15);1363});13641365test('calculates inputLineCount correctly from multiple ranges', () => {1366const uri = Uri.file('/test/file.ts');1367const comment = createTestReviewComment({1368request: {1369source: 'vscodeCopilotChat',1370promptCount: 1,1371messageId: 'test-message-id',1372inputType: 'change',1373inputRanges: [1374{ uri, ranges: [new Range(0, 0, 5, 0), new Range(10, 0, 15, 0)] },1375{ uri, ranges: [new Range(20, 0, 25, 0)] },1376],1377},1378});13791380sendReviewActionTelemetry(comment, 1, 'helpful', mockLogService, mockTelemetryService, mockInstantiationService);13811382// (5-0) + (15-10) + (25-20) = 5 + 5 + 5 = 151383assert.strictEqual(mockTelemetryService.msftEvents[0].measurements?.inputLineCount, 15);1384});13851386test('includes all expected properties', () => {1387const comment = createTestReviewComment();13881389sendReviewActionTelemetry(comment, 2, 'helpful', mockLogService, mockTelemetryService, mockInstantiationService);13901391const props = mockTelemetryService.msftEvents[0].properties;1392assert.strictEqual(props?.source, 'vscodeCopilotChat');1393assert.strictEqual(props?.requestId, 'test-message-id');1394assert.strictEqual(props?.documentType, 'text');1395assert.strictEqual(props?.languageId, 'typescript');1396assert.strictEqual(props?.inputType, 'change');1397assert.strictEqual(props?.commentType, 'bug');1398assert.strictEqual(props?.userAction, 'helpful');1399});14001401test('includes all expected measurements', () => {1402const comment = createTestReviewComment({ originalIndex: 3, actionCount: 2 });14031404sendReviewActionTelemetry(comment, 10, 'helpful', mockLogService, mockTelemetryService, mockInstantiationService);14051406const measures = mockTelemetryService.msftEvents[0].measurements;1407assert.strictEqual(measures?.commentIndex, 3);1408assert.strictEqual(measures?.actionCount, 2);1409assert.strictEqual(measures?.inputDocumentCount, 1);1410assert.strictEqual(measures?.promptCount, 1);1411assert.strictEqual(measures?.totalComments, 10);1412assert.strictEqual(measures?.comments, 1);1413});14141415test('triggers EditSurvivalReporter for discardComment action', () => {1416const comment = createTestReviewComment();14171418// Note: discardComment action tries to create EditSurvivalReporter which requires1419// additional services not available in unit tests. This test verifies the telemetry1420// path is correct before that point.1421try {1422sendReviewActionTelemetry(comment, 1, 'discardComment', mockLogService, mockTelemetryService, mockInstantiationService);1423} catch {1424// Expected: EditSurvivalReporter instantiation fails in unit test context1425}14261427// discardComment is a non-vote action, so actionCount should be incremented1428assert.strictEqual(comment.actionCount, 1);14291430// Should send review.comment.action telemetry (not vote)1431assert.strictEqual(mockTelemetryService.msftEvents.length, 1);1432assert.strictEqual(mockTelemetryService.msftEvents[0].eventName, 'review.comment.action');1433assert.strictEqual(mockTelemetryService.msftEvents[0].properties?.userAction, 'discardComment');1434});1435});1436});143714381439