Path: blob/main/extensions/copilot/src/extension/inlineEdits/test/node/nextEditProviderTelemetry.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 { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';6import { DocumentId } from '../../../../platform/inlineEdits/common/dataTypes/documentId';7import { MutableObservableDocument, MutableObservableWorkspace } from '../../../../platform/inlineEdits/common/observableWorkspace';8import { NullTelemetryService } from '../../../../platform/telemetry/common/nullTelemetryService';9import { TelemetryEventProperties } from '../../../../platform/telemetry/common/telemetry';10import { URI } from '../../../../util/vs/base/common/uri';11import { OffsetRange } from '../../../../util/vs/editor/common/core/ranges/offsetRange';12import { StringText } from '../../../../util/vs/editor/common/core/text/abstractText';13import { IEnhancedTelemetrySendingReason, NextEditProviderTelemetryBuilder, TelemetrySender } from '../../node/nextEditProviderTelemetry';14import { INextEditResult } from '../../node/nextEditResult';1516class RecordingTelemetryService extends NullTelemetryService {17readonly enhancedEvents: { eventName: string; properties?: TelemetryEventProperties }[] = [];1819override sendEnhancedGHTelemetryEvent(eventName: string, properties?: TelemetryEventProperties): void {20this.enhancedEvents.push({ eventName, properties });21}22}2324function createMockNextEditResult(): INextEditResult {25return { requestId: 1, result: undefined };26}2728function createMockBuilder(doc?: MutableObservableDocument): NextEditProviderTelemetryBuilder {29return new NextEditProviderTelemetryBuilder(30undefined, // gitExtensionService31undefined, // notebookService32undefined, // workspaceService33'test-provider',34doc,35);36}3738const workspaceRoot = URI.parse('file:///workspace');3940describe('TelemetrySender', () => {41let telemetryService: RecordingTelemetryService;42let sender: TelemetrySender;43let workspace: MutableObservableWorkspace;4445beforeEach(() => {46vi.useFakeTimers();47telemetryService = new RecordingTelemetryService();48workspace = new MutableObservableWorkspace();49sender = new TelemetrySender(workspace, telemetryService);50});5152afterEach(() => {53sender.dispose();54workspace.clear();55vi.useRealTimers();56});5758describe('scheduleSendingEnhancedTelemetry', () => {59const initialTimeoutMs = 2 * 60 * 1000; // matches production value6061test('sends after initial timeout when no workspace', async () => {62const senderNoWorkspace = new TelemetrySender(undefined, telemetryService);63const result = createMockNextEditResult();64const builder = createMockBuilder(undefined);6566senderNoWorkspace.scheduleSendingEnhancedTelemetry(result, builder);67expect(telemetryService.enhancedEvents).toHaveLength(0);6869await vi.advanceTimersByTimeAsync(initialTimeoutMs);7071expect(telemetryService.enhancedEvents).toHaveLength(1);72expect(telemetryService.enhancedEvents[0].eventName).toBe('copilot-nes/provideInlineEdit');73senderNoWorkspace.dispose();74});7576test('sends after initial timeout + 5s idle when user is not typing', async () => {77const doc = workspace.addDocument({ id: DocumentId.create('file:///test.ts'), workspaceRoot });78const result = createMockNextEditResult();79const builder = createMockBuilder(doc);8081sender.scheduleSendingEnhancedTelemetry(result, builder);82expect(telemetryService.enhancedEvents).toHaveLength(0);8384await vi.advanceTimersByTimeAsync(initialTimeoutMs);85expect(telemetryService.enhancedEvents).toHaveLength(0);8687// Advance 5s — idle timer fires88await vi.advanceTimersByTimeAsync(5_000);89expect(telemetryService.enhancedEvents).toHaveLength(1);90});9192test('resets idle timer when user types in any document during idle phase', async () => {93const doc = workspace.addDocument({ id: DocumentId.create('file:///test.ts'), workspaceRoot });94const otherDoc = workspace.addDocument({ id: DocumentId.create('file:///other.ts'), workspaceRoot });95const result = createMockNextEditResult();96const builder = createMockBuilder(doc);9798sender.scheduleSendingEnhancedTelemetry(result, builder);99100await vi.advanceTimersByTimeAsync(initialTimeoutMs);101expect(telemetryService.enhancedEvents).toHaveLength(0);102103// Wait 3s, then type in a DIFFERENT document104await vi.advanceTimersByTimeAsync(3_000);105otherDoc.setValue(new StringText('typing in other file'));106107// 3s after typing → still not sent108await vi.advanceTimersByTimeAsync(3_000);109expect(telemetryService.enhancedEvents).toHaveLength(0);110111// 2 more seconds → 5s since last activity → sends112await vi.advanceTimersByTimeAsync(2_000);113expect(telemetryService.enhancedEvents).toHaveLength(1);114});115116test('hard cap sends after 30s even if user keeps typing', async () => {117const doc = workspace.addDocument({ id: DocumentId.create('file:///test.ts'), workspaceRoot });118const result = createMockNextEditResult();119const builder = createMockBuilder(doc);120121sender.scheduleSendingEnhancedTelemetry(result, builder);122123await vi.advanceTimersByTimeAsync(initialTimeoutMs);124125// Simulate continuous typing every 2s for 30s126for (let i = 0; i < 15; i++) {127await vi.advanceTimersByTimeAsync(2_000);128doc.setValue(new StringText(`edit ${i}`));129}130131expect(telemetryService.enhancedEvents).toHaveLength(1);132});133134test('does not send twice', async () => {135const doc = workspace.addDocument({ id: DocumentId.create('file:///test.ts'), workspaceRoot });136const result = createMockNextEditResult();137const builder = createMockBuilder(doc);138139sender.scheduleSendingEnhancedTelemetry(result, builder);140141await vi.advanceTimersByTimeAsync(initialTimeoutMs + 5_000);142expect(telemetryService.enhancedEvents).toHaveLength(1);143144await vi.advanceTimersByTimeAsync(30_000);145expect(telemetryService.enhancedEvents).toHaveLength(1);146});147148test('dispose cancels pending initial timeout', async () => {149const result = createMockNextEditResult();150const builder = createMockBuilder(undefined);151152sender.scheduleSendingEnhancedTelemetry(result, builder);153sender.dispose();154155await vi.advanceTimersByTimeAsync(initialTimeoutMs + 5_000 + 30_000);156expect(telemetryService.enhancedEvents).toHaveLength(0);157});158159test('dispose during idle-wait phase cancels idle timers and subscription', async () => {160const doc = workspace.addDocument({ id: DocumentId.create('file:///test.ts'), workspaceRoot });161const result = createMockNextEditResult();162const builder = createMockBuilder(doc);163164sender.scheduleSendingEnhancedTelemetry(result, builder);165166// Advance past the 2-minute timeout to enter idle-wait phase167await vi.advanceTimersByTimeAsync(initialTimeoutMs);168expect(telemetryService.enhancedEvents).toHaveLength(0);169170// Dispose during idle-wait phase (before 5s idle timer fires)171sender.dispose();172173// Advance past both idle timer and hard cap — nothing should be sent174await vi.advanceTimersByTimeAsync(5_000 + 30_000);175expect(telemetryService.enhancedEvents).toHaveLength(0);176});177});178179describe('enhancedTelemetrySendingReason', () => {180const initialTimeoutMs = 2 * 60 * 1000;181182function getSendingReason(event: { properties?: TelemetryEventProperties }): IEnhancedTelemetrySendingReason | undefined {183// Sending reason may be at top level (when alternativeAction is absent) or embedded in alternativeAction184const topLevel = event.properties?.['enhancedTelemetrySendingReason'];185if (topLevel) { return JSON.parse(String(topLevel)); }186const altAction = event.properties?.['alternativeAction'];187if (altAction) {188const parsed = JSON.parse(String(altAction));189return parsed.enhancedTelemetrySendingReason;190}191return undefined;192}193194test('sends reason "idle" with idleTimeoutMs=0 when no workspace', async () => {195const senderNoWorkspace = new TelemetrySender(undefined, telemetryService);196const result = createMockNextEditResult();197const builder = createMockBuilder(undefined);198199senderNoWorkspace.scheduleSendingEnhancedTelemetry(result, builder);200await vi.advanceTimersByTimeAsync(initialTimeoutMs);201202expect(telemetryService.enhancedEvents).toHaveLength(1);203const reason = getSendingReason(telemetryService.enhancedEvents[0]);204expect(reason).toEqual({ reason: 'idle', details: { idleTimeoutMs: 0 } });205senderNoWorkspace.dispose();206});207208test('sends reason "idle" with idleTimeoutMs after 5s of inactivity', async () => {209workspace.addDocument({ id: DocumentId.create('file:///test.ts'), workspaceRoot });210const result = createMockNextEditResult();211const builder = createMockBuilder(undefined);212213sender.scheduleSendingEnhancedTelemetry(result, builder);214await vi.advanceTimersByTimeAsync(initialTimeoutMs + 5_000);215216expect(telemetryService.enhancedEvents).toHaveLength(1);217const reason = getSendingReason(telemetryService.enhancedEvents[0]);218expect(reason).toEqual({ reason: 'idle', details: { idleTimeoutMs: 5_000 } });219});220221test('sends reason "hard_cap" when user keeps typing past 30s', async () => {222const doc = workspace.addDocument({ id: DocumentId.create('file:///test.ts'), workspaceRoot });223const result = createMockNextEditResult();224const builder = createMockBuilder(doc);225226sender.scheduleSendingEnhancedTelemetry(result, builder);227await vi.advanceTimersByTimeAsync(initialTimeoutMs);228229// Simulate continuous typing every 2s for 30s230for (let i = 0; i < 15; i++) {231await vi.advanceTimersByTimeAsync(2_000);232doc.setValue(new StringText(`edit ${i}`));233}234235expect(telemetryService.enhancedEvents).toHaveLength(1);236const reason = getSendingReason(telemetryService.enhancedEvents[0]);237expect(reason).toEqual({ reason: 'hard_cap', details: { hardCapTimeoutMs: 30_000 } });238});239240test('sends reason "user_jump" with from/to when selection moves to different line in same file', async () => {241const doc = workspace.addDocument({ id: DocumentId.create('file:///test.ts'), workspaceRoot, initialValue: 'line0\nline1\nline2' });242const result = createMockNextEditResult();243244// Set initial selection on line 0 BEFORE creating builder (so originalSelectionLine is captured)245doc.setSelection([OffsetRange.fromTo(0, 0)], undefined, 0);246const builder = createMockBuilder(doc);247248sender.scheduleSendingEnhancedTelemetry(result, builder);249await vi.advanceTimersByTimeAsync(initialTimeoutMs);250expect(telemetryService.enhancedEvents).toHaveLength(0);251252// Wait 1s (no recent typing), then jump selection to line 2 (offset 12 = start of "line2")253await vi.advanceTimersByTimeAsync(1_000);254doc.setSelection([OffsetRange.fromTo(12, 12)], undefined, 2);255256await vi.advanceTimersByTimeAsync(0); // flush257expect(telemetryService.enhancedEvents).toHaveLength(1);258const reason = getSendingReason(telemetryService.enhancedEvents[0]);259expect(reason).toEqual({260reason: 'user_jump',261details: {262from: { file: 'file:///test.ts', line: 0 },263to: { file: 'file:///test.ts', line: 2 },264},265});266});267268test('sends reason "user_jump" when user jumps to a different file', async () => {269const doc = workspace.addDocument({ id: DocumentId.create('file:///test.ts'), workspaceRoot, initialValue: 'line0\nline1' });270const otherDoc = workspace.addDocument({ id: DocumentId.create('file:///other.ts'), workspaceRoot, initialValue: 'other0\nother1\nother2' });271const result = createMockNextEditResult();272273// Set initial selection BEFORE creating builder274doc.setSelection([OffsetRange.fromTo(0, 0)], undefined, 0);275const builder = createMockBuilder(doc);276277sender.scheduleSendingEnhancedTelemetry(result, builder);278await vi.advanceTimersByTimeAsync(initialTimeoutMs);279expect(telemetryService.enhancedEvents).toHaveLength(0);280281// Wait 1s, then "jump" to other file by changing its selection282await vi.advanceTimersByTimeAsync(1_000);283otherDoc.setSelection([OffsetRange.fromTo(7, 7)], undefined, 1); // line 1 of other.ts ("other1")284285await vi.advanceTimersByTimeAsync(0); // flush286expect(telemetryService.enhancedEvents).toHaveLength(1);287const reason = getSendingReason(telemetryService.enhancedEvents[0]);288expect(reason).toEqual({289reason: 'user_jump',290details: {291from: { file: 'file:///test.ts', line: 0 },292to: { file: 'file:///other.ts', line: 1 },293},294});295});296297test('does not trigger user_jump for selection change on same line', async () => {298const doc = workspace.addDocument({ id: DocumentId.create('file:///test.ts'), workspaceRoot, initialValue: 'hello world' });299const result = createMockNextEditResult();300301doc.setSelection([OffsetRange.fromTo(0, 0)], undefined, 0);302const builder = createMockBuilder(doc);303304sender.scheduleSendingEnhancedTelemetry(result, builder);305await vi.advanceTimersByTimeAsync(initialTimeoutMs);306307// Move selection within the same line (offset 5 is still line 0)308await vi.advanceTimersByTimeAsync(1_000);309doc.setSelection([OffsetRange.fromTo(5, 5)], undefined, 0);310311await vi.advanceTimersByTimeAsync(0);312expect(telemetryService.enhancedEvents).toHaveLength(0);313314// Eventually sends via idle timer315await vi.advanceTimersByTimeAsync(5_000);316expect(telemetryService.enhancedEvents).toHaveLength(1);317expect(getSendingReason(telemetryService.enhancedEvents[0])?.reason).toBe('idle');318});319320test('does not trigger user_jump for selection change during typing', async () => {321const doc = workspace.addDocument({ id: DocumentId.create('file:///test.ts'), workspaceRoot, initialValue: 'line0\nline1\nline2' });322const result = createMockNextEditResult();323324doc.setSelection([OffsetRange.fromTo(0, 0)], undefined, 0);325const builder = createMockBuilder(doc);326327sender.scheduleSendingEnhancedTelemetry(result, builder);328await vi.advanceTimersByTimeAsync(initialTimeoutMs);329330// Type first (triggers lastTypingTime update), then immediately move selection331doc.setValue(new StringText('line0\nline1\nline2!'));332doc.setSelection([OffsetRange.fromTo(12, 12)], undefined, 2);333334await vi.advanceTimersByTimeAsync(0);335// Should NOT have sent — selection change within 200ms of typing is ignored336expect(telemetryService.enhancedEvents).toHaveLength(0);337338// Sends via idle timer instead339await vi.advanceTimersByTimeAsync(5_000);340expect(telemetryService.enhancedEvents).toHaveLength(1);341expect(getSendingReason(telemetryService.enhancedEvents[0])?.reason).toBe('idle');342});343344test('pre-existing selection on another file does not trigger false jump', async () => {345const doc = workspace.addDocument({ id: DocumentId.create('file:///test.ts'), workspaceRoot, initialValue: 'line0\nline1' });346const otherDoc = workspace.addDocument({ id: DocumentId.create('file:///other.ts'), workspaceRoot, initialValue: 'other0\nother1' });347348// Both docs have pre-existing selections before idle-wait starts349doc.setSelection([OffsetRange.fromTo(0, 0)], undefined, 0);350otherDoc.setSelection([OffsetRange.fromTo(7, 7)], undefined, 1);351352const result = createMockNextEditResult();353const builder = createMockBuilder(doc);354355sender.scheduleSendingEnhancedTelemetry(result, builder);356await vi.advanceTimersByTimeAsync(initialTimeoutMs);357358// No selection changes — should NOT trigger user_jump359await vi.advanceTimersByTimeAsync(1_000);360expect(telemetryService.enhancedEvents).toHaveLength(0);361362// Eventually sends via idle timer363await vi.advanceTimersByTimeAsync(5_000);364expect(telemetryService.enhancedEvents).toHaveLength(1);365expect(getSendingReason(telemetryService.enhancedEvents[0])?.reason).toBe('idle');366});367368test('sendTelemetry during idle-wait cancels pending idle timers', async () => {369const doc = workspace.addDocument({ id: DocumentId.create('file:///test.ts'), workspaceRoot });370const result = createMockNextEditResult();371const builder = createMockBuilder(doc);372373sender.scheduleSendingEnhancedTelemetry(result, builder);374375// Enter idle-wait phase376await vi.advanceTimersByTimeAsync(initialTimeoutMs);377expect(telemetryService.enhancedEvents).toHaveLength(0);378379// Send via the direct path (error/cancel scenario)380const directBuilder = createMockBuilder(undefined);381sender.sendTelemetry(result, directBuilder);382383// Flush async _doSendEnhancedTelemetry384await vi.advanceTimersByTimeAsync(0);385expect(telemetryService.enhancedEvents).toHaveLength(1);386387// Advance past idle and hard cap — should NOT send again388await vi.advanceTimersByTimeAsync(5_000 + 30_000);389expect(telemetryService.enhancedEvents).toHaveLength(1);390});391392test('sends undefined "from" when builder has no doc', async () => {393workspace.addDocument({ id: DocumentId.create('file:///test.ts'), workspaceRoot, initialValue: 'line0\nline1' });394const result = createMockNextEditResult();395// No doc on builder — nesDocId and nesDocLine will be undefined396const builder = createMockBuilder(undefined);397398sender.scheduleSendingEnhancedTelemetry(result, builder);399await vi.advanceTimersByTimeAsync(initialTimeoutMs);400401// Idle sends — reason should have no nesDocId so from is undefined402await vi.advanceTimersByTimeAsync(5_000);403expect(telemetryService.enhancedEvents).toHaveLength(1);404const reason = getSendingReason(telemetryService.enhancedEvents[0]);405expect(reason).toEqual({ reason: 'idle', details: { idleTimeoutMs: 5_000 } });406});407408test('rescheduling for same result cancels previous schedule', async () => {409const doc = workspace.addDocument({ id: DocumentId.create('file:///test.ts'), workspaceRoot });410const result = createMockNextEditResult();411const builder1 = createMockBuilder(doc);412const builder2 = createMockBuilder(doc);413414sender.scheduleSendingEnhancedTelemetry(result, builder1);415416// After 1 minute, reschedule with a new builder417await vi.advanceTimersByTimeAsync(60_000);418sender.scheduleSendingEnhancedTelemetry(result, builder2);419420// Original 2-min timeout would fire at 120s, but it was cancelled421// New 2-min timeout fires at 60s + 120s = 180s422await vi.advanceTimersByTimeAsync(60_000); // at 120s total423expect(telemetryService.enhancedEvents).toHaveLength(0); // old one cancelled424425await vi.advanceTimersByTimeAsync(60_000 + 5_000); // at 185s total — new timeout + idle426expect(telemetryService.enhancedEvents).toHaveLength(1);427});428});429});430431432