Path: blob/main/extensions/copilot/src/extension/inlineEdits/vscode-node/components/test/nesFeedbackSubmitter.spec.ts
13406 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 { beforeEach, describe, expect, test } from 'vitest';6import { LogEntry } from '../../../../../platform/workspaceRecorder/common/workspaceLog';7import { FeedbackFile, NesFeedbackSubmitter } from '../nesFeedbackSubmitter';8import { TestLogService } from '../../../../../platform/testing/common/testLogService';910/**11* Creates a minimal test instance of NesFeedbackSubmitter for testing private methods.12* We use a subclass to expose private methods for testing.13*/14class TestableNesFeedbackSubmitter extends NesFeedbackSubmitter {15constructor() {16// Create minimal mock implementations17const mockAuthService = {18_serviceBrand: undefined,19isMinimalMode: false,20onDidAuthenticationChange: { dispose: () => { } },21onDidAccessTokenChange: { dispose: () => { } },22onDidAdoAuthenticationChange: { dispose: () => { } },23anyGitHubSession: undefined,24permissiveGitHubSession: undefined,25getGitHubSession: async () => undefined,26getCopilotToken: async () => undefined,27copilotToken: undefined,28resetCopilotToken: () => { },29speculativeDecodingEndpointToken: undefined,30getAdoAccessTokenBase64: async () => undefined31};3233const mockFetcherService = {34_serviceBrand: undefined,35fetch: async () => new Response(),36getUserAgentLibrary: () => 'test-agent'37};3839super(new TestLogService(), mockAuthService as any, mockFetcherService as any);40}4142// Expose private methods for testing43public testExtractDocumentPathsFromRecordings(files: FeedbackFile[]): string[] {44return (this as any)._extractDocumentPathsFromRecordings(files);45}4647public testFilterRecordingsByExcludedPaths(files: FeedbackFile[], excludedPaths: string[]): FeedbackFile[] {48// Compute nextUserEditPaths for the test (mimics what submitFromFolder does)49const nextUserEditPaths = new Map<string, string | undefined>();50for (const file of files) {51if (file.name.endsWith('.recording.w.json')) {52try {53const recording = JSON.parse(file.content) as { nextUserEdit?: { relativePath: string } };54nextUserEditPaths.set(file.name, recording.nextUserEdit?.relativePath);55} catch {56nextUserEditPaths.set(file.name, undefined);57}58}59}60return (this as any)._filterRecordingsByExcludedPaths(files, excludedPaths, nextUserEditPaths);61}6263public testFilterSingleRecording(file: FeedbackFile, excludedPathSet: Set<string>): FeedbackFile {64return (this as any)._filterSingleRecording(file, excludedPathSet);65}66}6768describe('NesFeedbackSubmitter', () => {69let submitter: TestableNesFeedbackSubmitter;7071beforeEach(() => {72submitter = new TestableNesFeedbackSubmitter();73});7475describe('extractDocumentPathsFromRecordings', () => {76test('should extract unique document paths from recording files', () => {77const files: FeedbackFile[] = [78{79name: 'capture-1.recording.w.json',80content: JSON.stringify({81log: [82{ kind: 'header', documentType: '[email protected]', repoRootUri: 'file:///repo', time: 0, uuid: 'test' },83{ kind: 'documentEncountered', id: 1, relativePath: 'src/index.ts', time: 0 },84{ kind: 'documentEncountered', id: 2, relativePath: 'src/utils.ts', time: 0 },85] satisfies LogEntry[]86})87},88{89name: 'capture-2.recording.w.json',90content: JSON.stringify({91log: [92{ kind: 'header', documentType: '[email protected]', repoRootUri: 'file:///repo', time: 0, uuid: 'test2' },93{ kind: 'documentEncountered', id: 1, relativePath: 'src/index.ts', time: 0 },94{ kind: 'documentEncountered', id: 2, relativePath: 'src/other.ts', time: 0 },95] satisfies LogEntry[]96})97}98];99100const result = submitter.testExtractDocumentPathsFromRecordings(files);101102expect(result).toEqual(['src/index.ts', 'src/other.ts', 'src/utils.ts']);103});104105test('should skip metadata files', () => {106const files: FeedbackFile[] = [107{108name: 'capture-1.recording.w.json',109content: JSON.stringify({110log: [111{ kind: 'header', documentType: '[email protected]', repoRootUri: 'file:///repo', time: 0, uuid: 'test' },112{ kind: 'documentEncountered', id: 1, relativePath: 'src/index.ts', time: 0 },113] satisfies LogEntry[]114})115},116{117name: 'capture-1.metadata.json',118content: JSON.stringify({119captureTimestamp: '2025-01-01T00:00:00Z',120trigger: 'manual'121})122}123];124125const result = submitter.testExtractDocumentPathsFromRecordings(files);126127expect(result).toEqual(['src/index.ts']);128});129130test('should handle files with invalid JSON gracefully', () => {131const files: FeedbackFile[] = [132{133name: 'capture-1.recording.w.json',134content: 'invalid json {'135},136{137name: 'capture-2.recording.w.json',138content: JSON.stringify({139log: [140{ kind: 'header', documentType: '[email protected]', repoRootUri: 'file:///repo', time: 0, uuid: 'test' },141{ kind: 'documentEncountered', id: 1, relativePath: 'src/valid.ts', time: 0 },142] satisfies LogEntry[]143})144}145];146147const result = submitter.testExtractDocumentPathsFromRecordings(files);148149expect(result).toEqual(['src/valid.ts']);150});151152test('should return empty array for files without log entries', () => {153const files: FeedbackFile[] = [154{155name: 'capture-1.recording.w.json',156content: JSON.stringify({ someOtherData: true })157}158];159160const result = submitter.testExtractDocumentPathsFromRecordings(files);161162expect(result).toEqual([]);163});164165test('should return sorted paths', () => {166const files: FeedbackFile[] = [167{168name: 'capture-1.recording.w.json',169content: JSON.stringify({170log: [171{ kind: 'header', documentType: '[email protected]', repoRootUri: 'file:///repo', time: 0, uuid: 'test' },172{ kind: 'documentEncountered', id: 1, relativePath: 'z-file.ts', time: 0 },173{ kind: 'documentEncountered', id: 2, relativePath: 'a-file.ts', time: 0 },174{ kind: 'documentEncountered', id: 3, relativePath: 'm-file.ts', time: 0 },175] satisfies LogEntry[]176})177}178];179180const result = submitter.testExtractDocumentPathsFromRecordings(files);181182expect(result).toEqual(['a-file.ts', 'm-file.ts', 'z-file.ts']);183});184});185186describe('filterRecordingsByExcludedPaths', () => {187test('should return files unchanged when no paths are excluded', () => {188const files: FeedbackFile[] = [189{190name: 'capture-1.recording.w.json',191content: JSON.stringify({192log: [193{ kind: 'header', documentType: '[email protected]', repoRootUri: 'file:///repo', time: 0, uuid: 'test' },194{ kind: 'documentEncountered', id: 1, relativePath: 'src/keep.ts', time: 0 },195{ kind: 'documentEncountered', id: 2, relativePath: 'src/also-keep.ts', time: 0 },196{ kind: 'changed', id: 1, edit: [], v: 1, time: 1 },197{ kind: 'changed', id: 2, edit: [], v: 1, time: 2 },198] satisfies LogEntry[]199})200}201];202203const result = submitter.testFilterRecordingsByExcludedPaths(files, []);204205// Should return exact same array reference (fast path)206expect(result).toBe(files);207});208209test('should filter out excluded documents from recordings', () => {210const files: FeedbackFile[] = [211{212name: 'capture-1.recording.w.json',213content: JSON.stringify({214log: [215{ kind: 'header', documentType: '[email protected]', repoRootUri: 'file:///repo', time: 0, uuid: 'test' },216{ kind: 'documentEncountered', id: 1, relativePath: 'src/keep.ts', time: 0 },217{ kind: 'documentEncountered', id: 2, relativePath: 'src/exclude.ts', time: 0 },218{ kind: 'changed', id: 1, edit: [], v: 1, time: 1 },219{ kind: 'changed', id: 2, edit: [], v: 1, time: 2 },220] satisfies LogEntry[],221nextUserEdit: { relativePath: 'src/keep.ts', edit: [] }222})223}224];225226const result = submitter.testFilterRecordingsByExcludedPaths(files, ['src/exclude.ts']);227228const parsed = JSON.parse(result[0].content);229expect(parsed.log).toHaveLength(3); // header + documentEncountered + changed for id 1230231const documentPaths = parsed.log232.filter((e: LogEntry) => e.kind === 'documentEncountered')233.map((e: any) => e.relativePath);234expect(documentPaths).toEqual(['src/keep.ts']);235});236237test('should pass through metadata files when their recording has nextUserEdit', () => {238const metadataContent = JSON.stringify({239captureTimestamp: '2025-01-01T00:00:00Z',240trigger: 'manual',241durationMs: 5000242});243244const files: FeedbackFile[] = [245{246name: 'capture-1.recording.w.json',247content: JSON.stringify({248log: [249{ kind: 'header', documentType: '[email protected]', repoRootUri: 'file:///repo', time: 0, uuid: 'test' },250{ kind: 'documentEncountered', id: 1, relativePath: 'src/file.ts', time: 0 },251] satisfies LogEntry[],252nextUserEdit: { relativePath: 'src/file.ts', edit: [] }253})254},255{256name: 'capture-1.metadata.json',257content: metadataContent258}259];260261const result = submitter.testFilterRecordingsByExcludedPaths(files, []);262263expect(result).toHaveLength(2);264expect(result.find(f => f.name === 'capture-1.metadata.json')?.content).toBe(metadataContent);265});266267test('should skip recording and metadata when nextUserEdit is excluded', () => {268const files: FeedbackFile[] = [269{270name: 'capture-1.recording.w.json',271content: JSON.stringify({272log: [273{ kind: 'header', documentType: '[email protected]', repoRootUri: 'file:///repo', time: 0, uuid: 'test' },274{ kind: 'documentEncountered', id: 1, relativePath: 'src/keep.ts', time: 0 },275{ kind: 'documentEncountered', id: 2, relativePath: 'src/exclude.ts', time: 0 },276] satisfies LogEntry[],277nextUserEdit: {278relativePath: 'src/exclude.ts',279edit: []280}281})282},283{284name: 'capture-1.metadata.json',285content: JSON.stringify({ captureTimestamp: '2025-01-01T00:00:00Z' })286}287];288289const result = submitter.testFilterRecordingsByExcludedPaths(files, ['src/exclude.ts']);290291// Both recording and metadata should be skipped292expect(result).toHaveLength(0);293});294295test('should preserve nextUserEdit if its file is not excluded', () => {296const files: FeedbackFile[] = [297{298name: 'capture-1.recording.w.json',299content: JSON.stringify({300log: [301{ kind: 'header', documentType: '[email protected]', repoRootUri: 'file:///repo', time: 0, uuid: 'test' },302{ kind: 'documentEncountered', id: 1, relativePath: 'src/keep.ts', time: 0 },303] satisfies LogEntry[],304nextUserEdit: {305relativePath: 'src/keep.ts',306edit: [{ offset: 0, oldLength: 0, newText: 'hello' }]307}308})309},310{311name: 'capture-1.metadata.json',312content: JSON.stringify({ captureTimestamp: '2025-01-01T00:00:00Z' })313}314];315316const result = submitter.testFilterRecordingsByExcludedPaths(files, []);317318expect(result).toHaveLength(2);319const recording = result.find(f => f.name === 'capture-1.recording.w.json');320const parsed = JSON.parse(recording!.content);321expect(parsed.nextUserEdit).toBeDefined();322expect(parsed.nextUserEdit.relativePath).toBe('src/keep.ts');323});324325test('should skip recording without nextUserEdit entirely', () => {326const files: FeedbackFile[] = [327{328name: 'capture-1.recording.w.json',329content: JSON.stringify({330log: [331{ kind: 'header', documentType: '[email protected]', repoRootUri: 'file:///repo', time: 0, uuid: 'test' },332{ kind: 'documentEncountered', id: 1, relativePath: 'src/file.ts', time: 0 },333] satisfies LogEntry[]334// No nextUserEdit335})336},337{338name: 'capture-1.metadata.json',339content: JSON.stringify({ captureTimestamp: '2025-01-01T00:00:00Z' })340},341{342name: 'capture-2.recording.w.json',343content: JSON.stringify({344log: [345{ kind: 'header', documentType: '[email protected]', repoRootUri: 'file:///repo', time: 0, uuid: 'test2' },346{ kind: 'documentEncountered', id: 1, relativePath: 'src/other.ts', time: 0 },347] satisfies LogEntry[],348nextUserEdit: { relativePath: 'src/other.ts', edit: [] }349})350},351{352name: 'capture-2.metadata.json',353content: JSON.stringify({ captureTimestamp: '2025-01-01T00:00:01Z' })354}355];356357const result = submitter.testFilterRecordingsByExcludedPaths(files, ['src/file.ts']);358359// Only capture-2 should be included (both recording and metadata)360expect(result).toHaveLength(2);361expect(result.map(f => f.name).sort()).toEqual(['capture-2.metadata.json', 'capture-2.recording.w.json']);362});363364test('should always preserve header entries in included recordings', () => {365const files: FeedbackFile[] = [366{367name: 'capture-1.recording.w.json',368content: JSON.stringify({369log: [370{ kind: 'header', documentType: '[email protected]', repoRootUri: 'file:///repo', time: 0, uuid: 'test-uuid' },371{ kind: 'documentEncountered', id: 1, relativePath: 'src/exclude.ts', time: 0 },372{ kind: 'documentEncountered', id: 2, relativePath: 'src/keep.ts', time: 0 },373] satisfies LogEntry[],374nextUserEdit: { relativePath: 'src/keep.ts', edit: [] }375})376}377];378379const result = submitter.testFilterRecordingsByExcludedPaths(files, ['src/exclude.ts']);380381expect(result).toHaveLength(1);382const parsed = JSON.parse(result[0].content);383expect(parsed.log).toHaveLength(2); // header + documentEncountered for keep.ts384expect(parsed.log[0].kind).toBe('header');385expect(parsed.log[0].uuid).toBe('test-uuid');386});387388test('should skip files with invalid JSON (no parseable nextUserEdit)', () => {389const invalidContent = 'not valid json {{{';390const files: FeedbackFile[] = [391{392name: 'capture-1.recording.w.json',393content: invalidContent394}395];396397const result = submitter.testFilterRecordingsByExcludedPaths(files, ['anything']);398399// Files with invalid JSON are skipped because nextUserEdit cannot be determined400expect(result).toHaveLength(0);401});402});403404describe('filterSingleRecording', () => {405test('should filter all event types for excluded documents', () => {406const file: FeedbackFile = {407name: 'test.recording.w.json',408content: JSON.stringify({409log: [410{ kind: 'header', documentType: '[email protected]', repoRootUri: 'file:///repo', time: 0, uuid: 'test' },411{ kind: 'documentEncountered', id: 1, relativePath: 'src/keep.ts', time: 0 },412{ kind: 'documentEncountered', id: 2, relativePath: 'src/exclude.ts', time: 0 },413{ kind: 'setContent', id: 1, v: 1, content: 'keep content', time: 1 },414{ kind: 'setContent', id: 2, v: 1, content: 'exclude content', time: 2 },415{ kind: 'changed', id: 1, edit: [], v: 1, time: 3 },416{ kind: 'changed', id: 2, edit: [], v: 1, time: 4 },417{ kind: 'selectionChanged', id: 1, selection: [[0, 0]], time: 5 },418{ kind: 'selectionChanged', id: 2, selection: [[0, 0]], time: 6 },419] satisfies LogEntry[],420nextUserEdit: { relativePath: 'src/keep.ts', edit: [] }421})422};423424const result = submitter.testFilterSingleRecording(file, new Set(['src/exclude.ts']));425426const parsed = JSON.parse(result.content);427428// Should have: header, documentEncountered(1), setContent(1), changed(1), selectionChanged(1)429expect(parsed.log).toHaveLength(5);430431// Verify no entries for id 2432const entriesWithId2 = parsed.log.filter((e: any) => e.id === 2);433expect(entriesWithId2).toHaveLength(0);434435// Verify all entries for id 1 are present436const entriesWithId1 = parsed.log.filter((e: any) => e.id === 1);437expect(entriesWithId1).toHaveLength(4);438439// nextUserEdit should be preserved440expect(parsed.nextUserEdit).toBeDefined();441expect(parsed.nextUserEdit.relativePath).toBe('src/keep.ts');442});443444test('should return original file if no log property', () => {445const file: FeedbackFile = {446name: 'test.recording.w.json',447content: JSON.stringify({ someOtherProperty: 'value' })448};449450const result = submitter.testFilterSingleRecording(file, new Set(['anything']));451452expect(result).toBe(file);453});454455test('should preserve entries without id property', () => {456const file: FeedbackFile = {457name: 'test.recording.w.json',458content: JSON.stringify({459log: [460{ kind: 'header', documentType: '[email protected]', repoRootUri: 'file:///repo', time: 0, uuid: 'test' },461{ kind: 'meta', data: { customKey: 'customValue' } },462{ kind: 'bookmark', time: 100 },463{ kind: 'documentEncountered', id: 1, relativePath: 'src/excluded.ts', time: 0 },464] satisfies LogEntry[],465nextUserEdit: { relativePath: 'src/other.ts', edit: [] }466})467};468469const result = submitter.testFilterSingleRecording(file, new Set(['src/excluded.ts']));470471const parsed = JSON.parse(result.content);472473// Should have header, meta, bookmark (but not documentEncountered)474expect(parsed.log).toHaveLength(3);475expect(parsed.log.map((e: any) => e.kind)).toEqual(['header', 'meta', 'bookmark']);476// nextUserEdit is preserved (its path is not excluded)477expect(parsed.nextUserEdit).toBeDefined();478});479480test('should preserve nextUserEdit (caller is responsible for checking exclusion)', () => {481// Note: _filterSingleRecording assumes the caller already verified nextUserEdit is not excluded.482// The filtering of recordings with excluded nextUserEdit happens in _filterRecordingsByExcludedPaths.483const file: FeedbackFile = {484name: 'test.recording.w.json',485content: JSON.stringify({486log: [487{ kind: 'header', documentType: '[email protected]', repoRootUri: 'file:///repo', time: 0, uuid: 'test' },488{ kind: 'documentEncountered', id: 1, relativePath: 'src/keep.ts', time: 0 },489{ kind: 'documentEncountered', id: 2, relativePath: 'src/exclude.ts', time: 0 },490] satisfies LogEntry[],491nextUserEdit: { relativePath: 'src/keep.ts', edit: [] }492})493};494495const result = submitter.testFilterSingleRecording(file, new Set(['src/exclude.ts']));496497const parsed = JSON.parse(result.content);498// nextUserEdit is preserved (filtering happens at a higher level)499expect(parsed.nextUserEdit).toBeDefined();500expect(parsed.nextUserEdit.relativePath).toBe('src/keep.ts');501// But the excluded document is filtered out502const docPaths = parsed.log503.filter((e: any) => e.kind === 'documentEncountered')504.map((e: any) => e.relativePath);505expect(docPaths).toEqual(['src/keep.ts']);506});507});508509describe('performance', () => {510test('should filter large recordings efficiently', () => {511// Generate a large recording with 10,000 log entries across 100 documents512const documentCount = 100;513const entriesPerDocument = 100; // Total: 10,000 entries514const log: LogEntry[] = [515{ kind: 'header', documentType: '[email protected]', repoRootUri: 'file:///repo', time: 0, uuid: 'perf-test' }516];517518// Add document encounters and their events519for (let docId = 1; docId <= documentCount; docId++) {520log.push({ kind: 'documentEncountered', id: docId, relativePath: `src/file${docId}.ts`, time: docId });521522// Add multiple events per document523for (let i = 0; i < entriesPerDocument - 1; i++) {524const time = docId * 1000 + i;525if (i % 3 === 0) {526log.push({ kind: 'changed', id: docId, edit: [[i, i + 1, 'x']], v: i + 1, time });527} else if (i % 3 === 1) {528log.push({ kind: 'setContent', id: docId, v: i + 1, content: `content ${i}`, time });529} else {530log.push({ kind: 'selectionChanged', id: docId, selection: [[i, i + 1]], time });531}532}533}534535const largeFile: FeedbackFile = {536name: 'large-capture.recording.w.json',537// nextUserEdit points to an even file so it won't be excluded538content: JSON.stringify({ log, nextUserEdit: { relativePath: 'src/file2.ts', edit: [] } })539};540541// Exclude half the documents (odd-numbered files)542const excludedPaths = Array.from({ length: documentCount / 2 }, (_, i) => `src/file${i * 2 + 1}.ts`);543544// Measure filtering time545const startTime = performance.now();546const result = submitter.testFilterRecordingsByExcludedPaths([largeFile], excludedPaths);547const endTime = performance.now();548const durationMs = endTime - startTime;549550// Verify correctness - recording should be included since nextUserEdit is not excluded551expect(result).toHaveLength(1);552const parsed = JSON.parse(result[0].content);553const remainingDocCount = parsed.log.filter((e: any) => e.kind === 'documentEncountered').length;554expect(remainingDocCount).toBe(documentCount / 2);555556// Performance assertion: should complete within 100ms even for large files557// This threshold is conservative to avoid flaky tests on slower CI machines558expect(durationMs).toBeLessThan(100);559});560561});562});563564565