Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/inlineEdits/vscode-node/components/test/nesFeedbackSubmitter.spec.ts
13406 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { beforeEach, describe, expect, test } from 'vitest';
7
import { LogEntry } from '../../../../../platform/workspaceRecorder/common/workspaceLog';
8
import { FeedbackFile, NesFeedbackSubmitter } from '../nesFeedbackSubmitter';
9
import { TestLogService } from '../../../../../platform/testing/common/testLogService';
10
11
/**
12
* Creates a minimal test instance of NesFeedbackSubmitter for testing private methods.
13
* We use a subclass to expose private methods for testing.
14
*/
15
class TestableNesFeedbackSubmitter extends NesFeedbackSubmitter {
16
constructor() {
17
// Create minimal mock implementations
18
const mockAuthService = {
19
_serviceBrand: undefined,
20
isMinimalMode: false,
21
onDidAuthenticationChange: { dispose: () => { } },
22
onDidAccessTokenChange: { dispose: () => { } },
23
onDidAdoAuthenticationChange: { dispose: () => { } },
24
anyGitHubSession: undefined,
25
permissiveGitHubSession: undefined,
26
getGitHubSession: async () => undefined,
27
getCopilotToken: async () => undefined,
28
copilotToken: undefined,
29
resetCopilotToken: () => { },
30
speculativeDecodingEndpointToken: undefined,
31
getAdoAccessTokenBase64: async () => undefined
32
};
33
34
const mockFetcherService = {
35
_serviceBrand: undefined,
36
fetch: async () => new Response(),
37
getUserAgentLibrary: () => 'test-agent'
38
};
39
40
super(new TestLogService(), mockAuthService as any, mockFetcherService as any);
41
}
42
43
// Expose private methods for testing
44
public testExtractDocumentPathsFromRecordings(files: FeedbackFile[]): string[] {
45
return (this as any)._extractDocumentPathsFromRecordings(files);
46
}
47
48
public testFilterRecordingsByExcludedPaths(files: FeedbackFile[], excludedPaths: string[]): FeedbackFile[] {
49
// Compute nextUserEditPaths for the test (mimics what submitFromFolder does)
50
const nextUserEditPaths = new Map<string, string | undefined>();
51
for (const file of files) {
52
if (file.name.endsWith('.recording.w.json')) {
53
try {
54
const recording = JSON.parse(file.content) as { nextUserEdit?: { relativePath: string } };
55
nextUserEditPaths.set(file.name, recording.nextUserEdit?.relativePath);
56
} catch {
57
nextUserEditPaths.set(file.name, undefined);
58
}
59
}
60
}
61
return (this as any)._filterRecordingsByExcludedPaths(files, excludedPaths, nextUserEditPaths);
62
}
63
64
public testFilterSingleRecording(file: FeedbackFile, excludedPathSet: Set<string>): FeedbackFile {
65
return (this as any)._filterSingleRecording(file, excludedPathSet);
66
}
67
}
68
69
describe('NesFeedbackSubmitter', () => {
70
let submitter: TestableNesFeedbackSubmitter;
71
72
beforeEach(() => {
73
submitter = new TestableNesFeedbackSubmitter();
74
});
75
76
describe('extractDocumentPathsFromRecordings', () => {
77
test('should extract unique document paths from recording files', () => {
78
const files: FeedbackFile[] = [
79
{
80
name: 'capture-1.recording.w.json',
81
content: JSON.stringify({
82
log: [
83
{ kind: 'header', documentType: '[email protected]', repoRootUri: 'file:///repo', time: 0, uuid: 'test' },
84
{ kind: 'documentEncountered', id: 1, relativePath: 'src/index.ts', time: 0 },
85
{ kind: 'documentEncountered', id: 2, relativePath: 'src/utils.ts', time: 0 },
86
] satisfies LogEntry[]
87
})
88
},
89
{
90
name: 'capture-2.recording.w.json',
91
content: JSON.stringify({
92
log: [
93
{ kind: 'header', documentType: '[email protected]', repoRootUri: 'file:///repo', time: 0, uuid: 'test2' },
94
{ kind: 'documentEncountered', id: 1, relativePath: 'src/index.ts', time: 0 },
95
{ kind: 'documentEncountered', id: 2, relativePath: 'src/other.ts', time: 0 },
96
] satisfies LogEntry[]
97
})
98
}
99
];
100
101
const result = submitter.testExtractDocumentPathsFromRecordings(files);
102
103
expect(result).toEqual(['src/index.ts', 'src/other.ts', 'src/utils.ts']);
104
});
105
106
test('should skip metadata files', () => {
107
const files: FeedbackFile[] = [
108
{
109
name: 'capture-1.recording.w.json',
110
content: JSON.stringify({
111
log: [
112
{ kind: 'header', documentType: '[email protected]', repoRootUri: 'file:///repo', time: 0, uuid: 'test' },
113
{ kind: 'documentEncountered', id: 1, relativePath: 'src/index.ts', time: 0 },
114
] satisfies LogEntry[]
115
})
116
},
117
{
118
name: 'capture-1.metadata.json',
119
content: JSON.stringify({
120
captureTimestamp: '2025-01-01T00:00:00Z',
121
trigger: 'manual'
122
})
123
}
124
];
125
126
const result = submitter.testExtractDocumentPathsFromRecordings(files);
127
128
expect(result).toEqual(['src/index.ts']);
129
});
130
131
test('should handle files with invalid JSON gracefully', () => {
132
const files: FeedbackFile[] = [
133
{
134
name: 'capture-1.recording.w.json',
135
content: 'invalid json {'
136
},
137
{
138
name: 'capture-2.recording.w.json',
139
content: JSON.stringify({
140
log: [
141
{ kind: 'header', documentType: '[email protected]', repoRootUri: 'file:///repo', time: 0, uuid: 'test' },
142
{ kind: 'documentEncountered', id: 1, relativePath: 'src/valid.ts', time: 0 },
143
] satisfies LogEntry[]
144
})
145
}
146
];
147
148
const result = submitter.testExtractDocumentPathsFromRecordings(files);
149
150
expect(result).toEqual(['src/valid.ts']);
151
});
152
153
test('should return empty array for files without log entries', () => {
154
const files: FeedbackFile[] = [
155
{
156
name: 'capture-1.recording.w.json',
157
content: JSON.stringify({ someOtherData: true })
158
}
159
];
160
161
const result = submitter.testExtractDocumentPathsFromRecordings(files);
162
163
expect(result).toEqual([]);
164
});
165
166
test('should return sorted paths', () => {
167
const files: FeedbackFile[] = [
168
{
169
name: 'capture-1.recording.w.json',
170
content: JSON.stringify({
171
log: [
172
{ kind: 'header', documentType: '[email protected]', repoRootUri: 'file:///repo', time: 0, uuid: 'test' },
173
{ kind: 'documentEncountered', id: 1, relativePath: 'z-file.ts', time: 0 },
174
{ kind: 'documentEncountered', id: 2, relativePath: 'a-file.ts', time: 0 },
175
{ kind: 'documentEncountered', id: 3, relativePath: 'm-file.ts', time: 0 },
176
] satisfies LogEntry[]
177
})
178
}
179
];
180
181
const result = submitter.testExtractDocumentPathsFromRecordings(files);
182
183
expect(result).toEqual(['a-file.ts', 'm-file.ts', 'z-file.ts']);
184
});
185
});
186
187
describe('filterRecordingsByExcludedPaths', () => {
188
test('should return files unchanged when no paths are excluded', () => {
189
const files: FeedbackFile[] = [
190
{
191
name: 'capture-1.recording.w.json',
192
content: JSON.stringify({
193
log: [
194
{ kind: 'header', documentType: '[email protected]', repoRootUri: 'file:///repo', time: 0, uuid: 'test' },
195
{ kind: 'documentEncountered', id: 1, relativePath: 'src/keep.ts', time: 0 },
196
{ kind: 'documentEncountered', id: 2, relativePath: 'src/also-keep.ts', time: 0 },
197
{ kind: 'changed', id: 1, edit: [], v: 1, time: 1 },
198
{ kind: 'changed', id: 2, edit: [], v: 1, time: 2 },
199
] satisfies LogEntry[]
200
})
201
}
202
];
203
204
const result = submitter.testFilterRecordingsByExcludedPaths(files, []);
205
206
// Should return exact same array reference (fast path)
207
expect(result).toBe(files);
208
});
209
210
test('should filter out excluded documents from recordings', () => {
211
const files: FeedbackFile[] = [
212
{
213
name: 'capture-1.recording.w.json',
214
content: JSON.stringify({
215
log: [
216
{ kind: 'header', documentType: '[email protected]', repoRootUri: 'file:///repo', time: 0, uuid: 'test' },
217
{ kind: 'documentEncountered', id: 1, relativePath: 'src/keep.ts', time: 0 },
218
{ kind: 'documentEncountered', id: 2, relativePath: 'src/exclude.ts', time: 0 },
219
{ kind: 'changed', id: 1, edit: [], v: 1, time: 1 },
220
{ kind: 'changed', id: 2, edit: [], v: 1, time: 2 },
221
] satisfies LogEntry[],
222
nextUserEdit: { relativePath: 'src/keep.ts', edit: [] }
223
})
224
}
225
];
226
227
const result = submitter.testFilterRecordingsByExcludedPaths(files, ['src/exclude.ts']);
228
229
const parsed = JSON.parse(result[0].content);
230
expect(parsed.log).toHaveLength(3); // header + documentEncountered + changed for id 1
231
232
const documentPaths = parsed.log
233
.filter((e: LogEntry) => e.kind === 'documentEncountered')
234
.map((e: any) => e.relativePath);
235
expect(documentPaths).toEqual(['src/keep.ts']);
236
});
237
238
test('should pass through metadata files when their recording has nextUserEdit', () => {
239
const metadataContent = JSON.stringify({
240
captureTimestamp: '2025-01-01T00:00:00Z',
241
trigger: 'manual',
242
durationMs: 5000
243
});
244
245
const files: FeedbackFile[] = [
246
{
247
name: 'capture-1.recording.w.json',
248
content: JSON.stringify({
249
log: [
250
{ kind: 'header', documentType: '[email protected]', repoRootUri: 'file:///repo', time: 0, uuid: 'test' },
251
{ kind: 'documentEncountered', id: 1, relativePath: 'src/file.ts', time: 0 },
252
] satisfies LogEntry[],
253
nextUserEdit: { relativePath: 'src/file.ts', edit: [] }
254
})
255
},
256
{
257
name: 'capture-1.metadata.json',
258
content: metadataContent
259
}
260
];
261
262
const result = submitter.testFilterRecordingsByExcludedPaths(files, []);
263
264
expect(result).toHaveLength(2);
265
expect(result.find(f => f.name === 'capture-1.metadata.json')?.content).toBe(metadataContent);
266
});
267
268
test('should skip recording and metadata when nextUserEdit is excluded', () => {
269
const files: FeedbackFile[] = [
270
{
271
name: 'capture-1.recording.w.json',
272
content: JSON.stringify({
273
log: [
274
{ kind: 'header', documentType: '[email protected]', repoRootUri: 'file:///repo', time: 0, uuid: 'test' },
275
{ kind: 'documentEncountered', id: 1, relativePath: 'src/keep.ts', time: 0 },
276
{ kind: 'documentEncountered', id: 2, relativePath: 'src/exclude.ts', time: 0 },
277
] satisfies LogEntry[],
278
nextUserEdit: {
279
relativePath: 'src/exclude.ts',
280
edit: []
281
}
282
})
283
},
284
{
285
name: 'capture-1.metadata.json',
286
content: JSON.stringify({ captureTimestamp: '2025-01-01T00:00:00Z' })
287
}
288
];
289
290
const result = submitter.testFilterRecordingsByExcludedPaths(files, ['src/exclude.ts']);
291
292
// Both recording and metadata should be skipped
293
expect(result).toHaveLength(0);
294
});
295
296
test('should preserve nextUserEdit if its file is not excluded', () => {
297
const files: FeedbackFile[] = [
298
{
299
name: 'capture-1.recording.w.json',
300
content: JSON.stringify({
301
log: [
302
{ kind: 'header', documentType: '[email protected]', repoRootUri: 'file:///repo', time: 0, uuid: 'test' },
303
{ kind: 'documentEncountered', id: 1, relativePath: 'src/keep.ts', time: 0 },
304
] satisfies LogEntry[],
305
nextUserEdit: {
306
relativePath: 'src/keep.ts',
307
edit: [{ offset: 0, oldLength: 0, newText: 'hello' }]
308
}
309
})
310
},
311
{
312
name: 'capture-1.metadata.json',
313
content: JSON.stringify({ captureTimestamp: '2025-01-01T00:00:00Z' })
314
}
315
];
316
317
const result = submitter.testFilterRecordingsByExcludedPaths(files, []);
318
319
expect(result).toHaveLength(2);
320
const recording = result.find(f => f.name === 'capture-1.recording.w.json');
321
const parsed = JSON.parse(recording!.content);
322
expect(parsed.nextUserEdit).toBeDefined();
323
expect(parsed.nextUserEdit.relativePath).toBe('src/keep.ts');
324
});
325
326
test('should skip recording without nextUserEdit entirely', () => {
327
const files: FeedbackFile[] = [
328
{
329
name: 'capture-1.recording.w.json',
330
content: JSON.stringify({
331
log: [
332
{ kind: 'header', documentType: '[email protected]', repoRootUri: 'file:///repo', time: 0, uuid: 'test' },
333
{ kind: 'documentEncountered', id: 1, relativePath: 'src/file.ts', time: 0 },
334
] satisfies LogEntry[]
335
// No nextUserEdit
336
})
337
},
338
{
339
name: 'capture-1.metadata.json',
340
content: JSON.stringify({ captureTimestamp: '2025-01-01T00:00:00Z' })
341
},
342
{
343
name: 'capture-2.recording.w.json',
344
content: JSON.stringify({
345
log: [
346
{ kind: 'header', documentType: '[email protected]', repoRootUri: 'file:///repo', time: 0, uuid: 'test2' },
347
{ kind: 'documentEncountered', id: 1, relativePath: 'src/other.ts', time: 0 },
348
] satisfies LogEntry[],
349
nextUserEdit: { relativePath: 'src/other.ts', edit: [] }
350
})
351
},
352
{
353
name: 'capture-2.metadata.json',
354
content: JSON.stringify({ captureTimestamp: '2025-01-01T00:00:01Z' })
355
}
356
];
357
358
const result = submitter.testFilterRecordingsByExcludedPaths(files, ['src/file.ts']);
359
360
// Only capture-2 should be included (both recording and metadata)
361
expect(result).toHaveLength(2);
362
expect(result.map(f => f.name).sort()).toEqual(['capture-2.metadata.json', 'capture-2.recording.w.json']);
363
});
364
365
test('should always preserve header entries in included recordings', () => {
366
const files: FeedbackFile[] = [
367
{
368
name: 'capture-1.recording.w.json',
369
content: JSON.stringify({
370
log: [
371
{ kind: 'header', documentType: '[email protected]', repoRootUri: 'file:///repo', time: 0, uuid: 'test-uuid' },
372
{ kind: 'documentEncountered', id: 1, relativePath: 'src/exclude.ts', time: 0 },
373
{ kind: 'documentEncountered', id: 2, relativePath: 'src/keep.ts', time: 0 },
374
] satisfies LogEntry[],
375
nextUserEdit: { relativePath: 'src/keep.ts', edit: [] }
376
})
377
}
378
];
379
380
const result = submitter.testFilterRecordingsByExcludedPaths(files, ['src/exclude.ts']);
381
382
expect(result).toHaveLength(1);
383
const parsed = JSON.parse(result[0].content);
384
expect(parsed.log).toHaveLength(2); // header + documentEncountered for keep.ts
385
expect(parsed.log[0].kind).toBe('header');
386
expect(parsed.log[0].uuid).toBe('test-uuid');
387
});
388
389
test('should skip files with invalid JSON (no parseable nextUserEdit)', () => {
390
const invalidContent = 'not valid json {{{';
391
const files: FeedbackFile[] = [
392
{
393
name: 'capture-1.recording.w.json',
394
content: invalidContent
395
}
396
];
397
398
const result = submitter.testFilterRecordingsByExcludedPaths(files, ['anything']);
399
400
// Files with invalid JSON are skipped because nextUserEdit cannot be determined
401
expect(result).toHaveLength(0);
402
});
403
});
404
405
describe('filterSingleRecording', () => {
406
test('should filter all event types for excluded documents', () => {
407
const file: FeedbackFile = {
408
name: 'test.recording.w.json',
409
content: JSON.stringify({
410
log: [
411
{ kind: 'header', documentType: '[email protected]', repoRootUri: 'file:///repo', time: 0, uuid: 'test' },
412
{ kind: 'documentEncountered', id: 1, relativePath: 'src/keep.ts', time: 0 },
413
{ kind: 'documentEncountered', id: 2, relativePath: 'src/exclude.ts', time: 0 },
414
{ kind: 'setContent', id: 1, v: 1, content: 'keep content', time: 1 },
415
{ kind: 'setContent', id: 2, v: 1, content: 'exclude content', time: 2 },
416
{ kind: 'changed', id: 1, edit: [], v: 1, time: 3 },
417
{ kind: 'changed', id: 2, edit: [], v: 1, time: 4 },
418
{ kind: 'selectionChanged', id: 1, selection: [[0, 0]], time: 5 },
419
{ kind: 'selectionChanged', id: 2, selection: [[0, 0]], time: 6 },
420
] satisfies LogEntry[],
421
nextUserEdit: { relativePath: 'src/keep.ts', edit: [] }
422
})
423
};
424
425
const result = submitter.testFilterSingleRecording(file, new Set(['src/exclude.ts']));
426
427
const parsed = JSON.parse(result.content);
428
429
// Should have: header, documentEncountered(1), setContent(1), changed(1), selectionChanged(1)
430
expect(parsed.log).toHaveLength(5);
431
432
// Verify no entries for id 2
433
const entriesWithId2 = parsed.log.filter((e: any) => e.id === 2);
434
expect(entriesWithId2).toHaveLength(0);
435
436
// Verify all entries for id 1 are present
437
const entriesWithId1 = parsed.log.filter((e: any) => e.id === 1);
438
expect(entriesWithId1).toHaveLength(4);
439
440
// nextUserEdit should be preserved
441
expect(parsed.nextUserEdit).toBeDefined();
442
expect(parsed.nextUserEdit.relativePath).toBe('src/keep.ts');
443
});
444
445
test('should return original file if no log property', () => {
446
const file: FeedbackFile = {
447
name: 'test.recording.w.json',
448
content: JSON.stringify({ someOtherProperty: 'value' })
449
};
450
451
const result = submitter.testFilterSingleRecording(file, new Set(['anything']));
452
453
expect(result).toBe(file);
454
});
455
456
test('should preserve entries without id property', () => {
457
const file: FeedbackFile = {
458
name: 'test.recording.w.json',
459
content: JSON.stringify({
460
log: [
461
{ kind: 'header', documentType: '[email protected]', repoRootUri: 'file:///repo', time: 0, uuid: 'test' },
462
{ kind: 'meta', data: { customKey: 'customValue' } },
463
{ kind: 'bookmark', time: 100 },
464
{ kind: 'documentEncountered', id: 1, relativePath: 'src/excluded.ts', time: 0 },
465
] satisfies LogEntry[],
466
nextUserEdit: { relativePath: 'src/other.ts', edit: [] }
467
})
468
};
469
470
const result = submitter.testFilterSingleRecording(file, new Set(['src/excluded.ts']));
471
472
const parsed = JSON.parse(result.content);
473
474
// Should have header, meta, bookmark (but not documentEncountered)
475
expect(parsed.log).toHaveLength(3);
476
expect(parsed.log.map((e: any) => e.kind)).toEqual(['header', 'meta', 'bookmark']);
477
// nextUserEdit is preserved (its path is not excluded)
478
expect(parsed.nextUserEdit).toBeDefined();
479
});
480
481
test('should preserve nextUserEdit (caller is responsible for checking exclusion)', () => {
482
// Note: _filterSingleRecording assumes the caller already verified nextUserEdit is not excluded.
483
// The filtering of recordings with excluded nextUserEdit happens in _filterRecordingsByExcludedPaths.
484
const file: FeedbackFile = {
485
name: 'test.recording.w.json',
486
content: JSON.stringify({
487
log: [
488
{ kind: 'header', documentType: '[email protected]', repoRootUri: 'file:///repo', time: 0, uuid: 'test' },
489
{ kind: 'documentEncountered', id: 1, relativePath: 'src/keep.ts', time: 0 },
490
{ kind: 'documentEncountered', id: 2, relativePath: 'src/exclude.ts', time: 0 },
491
] satisfies LogEntry[],
492
nextUserEdit: { relativePath: 'src/keep.ts', edit: [] }
493
})
494
};
495
496
const result = submitter.testFilterSingleRecording(file, new Set(['src/exclude.ts']));
497
498
const parsed = JSON.parse(result.content);
499
// nextUserEdit is preserved (filtering happens at a higher level)
500
expect(parsed.nextUserEdit).toBeDefined();
501
expect(parsed.nextUserEdit.relativePath).toBe('src/keep.ts');
502
// But the excluded document is filtered out
503
const docPaths = parsed.log
504
.filter((e: any) => e.kind === 'documentEncountered')
505
.map((e: any) => e.relativePath);
506
expect(docPaths).toEqual(['src/keep.ts']);
507
});
508
});
509
510
describe('performance', () => {
511
test('should filter large recordings efficiently', () => {
512
// Generate a large recording with 10,000 log entries across 100 documents
513
const documentCount = 100;
514
const entriesPerDocument = 100; // Total: 10,000 entries
515
const log: LogEntry[] = [
516
{ kind: 'header', documentType: '[email protected]', repoRootUri: 'file:///repo', time: 0, uuid: 'perf-test' }
517
];
518
519
// Add document encounters and their events
520
for (let docId = 1; docId <= documentCount; docId++) {
521
log.push({ kind: 'documentEncountered', id: docId, relativePath: `src/file${docId}.ts`, time: docId });
522
523
// Add multiple events per document
524
for (let i = 0; i < entriesPerDocument - 1; i++) {
525
const time = docId * 1000 + i;
526
if (i % 3 === 0) {
527
log.push({ kind: 'changed', id: docId, edit: [[i, i + 1, 'x']], v: i + 1, time });
528
} else if (i % 3 === 1) {
529
log.push({ kind: 'setContent', id: docId, v: i + 1, content: `content ${i}`, time });
530
} else {
531
log.push({ kind: 'selectionChanged', id: docId, selection: [[i, i + 1]], time });
532
}
533
}
534
}
535
536
const largeFile: FeedbackFile = {
537
name: 'large-capture.recording.w.json',
538
// nextUserEdit points to an even file so it won't be excluded
539
content: JSON.stringify({ log, nextUserEdit: { relativePath: 'src/file2.ts', edit: [] } })
540
};
541
542
// Exclude half the documents (odd-numbered files)
543
const excludedPaths = Array.from({ length: documentCount / 2 }, (_, i) => `src/file${i * 2 + 1}.ts`);
544
545
// Measure filtering time
546
const startTime = performance.now();
547
const result = submitter.testFilterRecordingsByExcludedPaths([largeFile], excludedPaths);
548
const endTime = performance.now();
549
const durationMs = endTime - startTime;
550
551
// Verify correctness - recording should be included since nextUserEdit is not excluded
552
expect(result).toHaveLength(1);
553
const parsed = JSON.parse(result[0].content);
554
const remainingDocCount = parsed.log.filter((e: any) => e.kind === 'documentEncountered').length;
555
expect(remainingDocCount).toBe(documentCount / 2);
556
557
// Performance assertion: should complete within 100ms even for large files
558
// This threshold is conservative to avoid flaky tests on slower CI machines
559
expect(durationMs).toBeLessThan(100);
560
});
561
562
});
563
});
564
565