Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/test/simulation/inlineEdit/inlineEditTester.ts
13394 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 { rename } from 'fs/promises';
7
import { basename, dirname, join } from 'path';
8
import { VisualizationTestRun } from '../../../src/extension/inlineChat/node/rendererVisualization';
9
import { IRecordingInformation, ObservableWorkspaceRecordingReplayer } from '../../../src/extension/inlineEdits/common/observableWorkspaceRecordingReplayer';
10
import { createNextEditProvider } from '../../../src/extension/inlineEdits/node/createNextEditProvider';
11
import { DebugRecorder } from '../../../src/extension/inlineEdits/node/debugRecorder';
12
import { NESInlineCompletionContext, NextEditProvider } from '../../../src/extension/inlineEdits/node/nextEditProvider';
13
import { NextEditProviderTelemetryBuilder } from '../../../src/extension/inlineEdits/node/nextEditProviderTelemetry';
14
import { NextEditResult } from '../../../src/extension/inlineEdits/node/nextEditResult';
15
import { ConfigKey, IConfigurationService } from '../../../src/platform/configuration/common/configurationService';
16
import { IGitExtensionService } from '../../../src/platform/git/common/gitExtensionService';
17
import { DocumentId } from '../../../src/platform/inlineEdits/common/dataTypes/documentId';
18
import { RootedEdit } from '../../../src/platform/inlineEdits/common/dataTypes/edit';
19
import { deserializeStringEdit, SerializedEdit, serializeStringEdit } from '../../../src/platform/inlineEdits/common/dataTypes/editUtils';
20
import { InlineEditRequestLogContext } from '../../../src/platform/inlineEdits/common/inlineEditLogContext';
21
import { ObservableGit } from '../../../src/platform/inlineEdits/common/observableGit';
22
import { ObservableWorkspace } from '../../../src/platform/inlineEdits/common/observableWorkspace';
23
import { IHistoryContextProvider } from '../../../src/platform/inlineEdits/common/workspaceEditTracker/historyContextProvider';
24
import { NesHistoryContextProvider } from '../../../src/platform/inlineEdits/common/workspaceEditTracker/nesHistoryContextProvider';
25
import { NesXtabHistoryTracker } from '../../../src/platform/inlineEdits/common/workspaceEditTracker/nesXtabHistoryTracker';
26
import { INotebookService } from '../../../src/platform/notebook/common/notebookService';
27
import { IExperimentationService } from '../../../src/platform/telemetry/common/nullExperimentationService';
28
import { TestingServiceCollection } from '../../../src/platform/test/node/services';
29
import { IWorkspaceService } from '../../../src/platform/workspace/common/workspaceService';
30
import { TaskQueue } from '../../../src/util/common/async';
31
import { getLanguageForResource } from '../../../src/util/common/languages';
32
import { CachedFunction } from '../../../src/util/vs/base/common/cache';
33
import { CancellationToken } from '../../../src/util/vs/base/common/cancellation';
34
import { BugIndicatingError } from '../../../src/util/vs/base/common/errors';
35
import { assertReturnsDefined } from '../../../src/util/vs/base/common/types';
36
import { URI } from '../../../src/util/vs/base/common/uri';
37
import { generateUuid } from '../../../src/util/vs/base/common/uuid';
38
import { StringEdit, StringReplacement } from '../../../src/util/vs/editor/common/core/edits/stringEdit';
39
import { StringText } from '../../../src/util/vs/editor/common/core/text/abstractText';
40
import { IInstantiationService, ServicesAccessor } from '../../../src/util/vs/platform/instantiation/common/instantiation';
41
import { ISimulationTestRuntime } from '../../base/stest';
42
import { CriticalError } from '../../testExecutor';
43
import { fileExists } from '../../util';
44
import { ISerializedFileEdit, ISerializedNesUserEditsHistory, NES_LOG_CONTEXT_TAG, NES_USER_EDITS_HISTORY_TAG, NEXT_EDIT_SUGGESTION_TAG } from '../shared/sharedTypes';
45
import { ITestInformation } from '../testInformation';
46
import { IInlineEditBaseFile, ILoadedFile } from './fileLoading';
47
import { inlineEditScoringService } from './inlineEditScoringService';
48
49
export interface IInlineEditTest {
50
recentEdit: IInlineEditTestDocument | IInlineEditTestDocument[];
51
}
52
53
export interface IInlineEditTestDocument {
54
base: IInlineEditBaseFile;
55
edit: SerializedEdit;
56
}
57
58
export interface IInlineEditTestRunResult {
59
aiEditDocumentUri: DocumentId;
60
aiEditDocumentValue: StringText;
61
nextUserEdit: StringEdit;
62
recordingData: IRecordingInformation;
63
aiRootedEdit?: RootedEdit;
64
aiEdit?: StringEdit;
65
nextEdit?: StringReplacement;
66
textAfterAiEdit?: StringText;
67
}
68
69
export class EditNotScoredError extends CriticalError {
70
constructor(scoredEditsFilePath: string) {
71
super(`Edit is not scored yet in \n${scoredEditsFilePath}\n! Please manually score it and run the test again.`);
72
}
73
}
74
75
export type NextCursorPosition = /* lineNumber (1-based) */ number | { cursorOffset: number };
76
77
export class InlineEditTester {
78
constructor(
79
private readonly _includeNextEditSelection: boolean = false,
80
) { }
81
82
private readonly _renameQueues = new CachedFunction({ getCacheKey: JSON.stringify }, (arg: unknown) => new TaskQueue());
83
84
async runAndScoreTestFromRecording(collection: TestingServiceCollection, recording: ILoadedFile): Promise<void> {
85
const { isScored, scoredEditsFilePath } = await this.runAndScoreFromRecording(collection, recording);
86
if (!isScored) {
87
throw new EditNotScoredError(scoredEditsFilePath);
88
}
89
}
90
91
async runAndScoreFromRecording(collectionOrAccessor: TestingServiceCollection | ServicesAccessor, recording: ILoadedFile): Promise<{ result: IInlineEditTestRunResult; scoredEditsFilePath: string; isScored: boolean }> {
92
const accessor = collectionOrAccessor instanceof TestingServiceCollection ? collectionOrAccessor.createTestingAccessor() : collectionOrAccessor;
93
const recordingData = JSON.parse(recording.fileContents) as IRecordingInformation;
94
const result = await this.runTestFromRecording(accessor, recordingData);
95
const testInfo = accessor.get(ITestInformation);
96
97
const scoredEditsFilePath = await this._renameQueues.get([testInfo.testFileName, recording.filePath]).schedule(() => getScoredEditsFilePath(testInfo, recording.filePath));
98
99
const score = await inlineEditScoringService.scoreEdit(scoredEditsFilePath, { kind: 'recording', recording: result.recordingData }, result.aiEditDocumentUri, result.aiEditDocumentValue, result.aiRootedEdit);
100
101
if (!score) {
102
return { result, scoredEditsFilePath, isScored: false };
103
} else {
104
const runtime = accessor.get(ISimulationTestRuntime);
105
runtime.setExplicitScore(score.getScoreValue());
106
return { result, scoredEditsFilePath, isScored: true };
107
}
108
}
109
110
async runTestFromRecording(accessor: ServicesAccessor, recordingData: IRecordingInformation): Promise<IInlineEditTestRunResult> {
111
const replayer = new ObservableWorkspaceRecordingReplayer(recordingData, this._includeNextEditSelection);
112
const obsGit = accessor.get(IInstantiationService).createInstance(ObservableGit);
113
const historyContextProvider = new NesHistoryContextProvider(replayer.workspace, obsGit);
114
const nesXtabHistoryTracker = new NesXtabHistoryTracker(replayer.workspace, undefined, accessor.get(IConfigurationService), accessor.get(IExperimentationService));
115
const debugRecorder = new DebugRecorder(replayer.workspace);
116
117
const { lastDocId } = replayer.replay();
118
119
const expectedEdit = deserializeStringEdit(recordingData.nextUserEdit?.edit ?? serializeStringEdit(StringEdit.empty));
120
const result = await this._runTest(accessor, lastDocId, replayer.workspace, historyContextProvider, nesXtabHistoryTracker, debugRecorder);
121
122
const r = { ...result, nextUserEdit: expectedEdit, recordingData };
123
return r;
124
}
125
126
private async _runTest(accessor: ServicesAccessor, docId: DocumentId, workspace: ObservableWorkspace, historyContextProvider: IHistoryContextProvider, nesXtabHistoryTracker: NesXtabHistoryTracker, debugRecorder: DebugRecorder | undefined) {
127
const instaService = accessor.get(IInstantiationService);
128
const configService = accessor.get(IConfigurationService);
129
const expService = accessor.get(IExperimentationService);
130
const gitExtensionService = accessor.get(IGitExtensionService);
131
const notebookService = accessor.get(INotebookService);
132
const workspaceService = accessor.get(IWorkspaceService);
133
134
const history = historyContextProvider.getHistoryContext(docId)!;
135
let i = 0;
136
for (const e of history.documents) {
137
i++;
138
VisualizationTestRun.instance?.addData('recentEdit_' + i, () => ({
139
...{ $fileExtension: 'diff.w' },
140
original: e.lastEdit.base.value,
141
modified: e.lastEdit.getEditedState().value,
142
}));
143
}
144
145
const stestRuntime = (() => {
146
try {
147
return accessor.get(ISimulationTestRuntime);
148
} catch {
149
return undefined;
150
}
151
})();
152
153
if (stestRuntime) {
154
const nesUserEditHistory: ISerializedNesUserEditsHistory = {
155
edits: history.documents.map((doc): ISerializedFileEdit => ({
156
id: getUserFriendlyFilePath(doc.docId),
157
languageId: getLanguageIdFromDocumentId(doc.docId),
158
original: doc.lastEdit.base.value,
159
modified: doc.lastEdit.getEditedState().value,
160
})),
161
currentDocumentIndex: history.documents.length - 1,
162
};
163
stestRuntime.writeFile('nesUserEditHistory.json', JSON.stringify(nesUserEditHistory, null, 2), NES_USER_EDITS_HISTORY_TAG);
164
}
165
166
const nextEditProviderId = configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsProviderId, expService);
167
const statelessNextEditProvider = createNextEditProvider(nextEditProviderId, instaService);
168
const nextEditProvider = instaService.createInstance(NextEditProvider, workspace, statelessNextEditProvider, historyContextProvider, nesXtabHistoryTracker, debugRecorder);
169
170
const historyContext = historyContextProvider.getHistoryContext(docId)!;
171
const activeDocument = historyContext.getMostRecentDocument(); // TODO
172
const context: NESInlineCompletionContext = { triggerKind: 1, selectedCompletionInfo: undefined, requestUuid: generateUuid(), requestIssuedDateTime: Date.now(), earliestShownDateTime: Date.now() + 200, enforceCacheDelay: false };
173
const logContext = new InlineEditRequestLogContext(activeDocument.docId.toString(), 1, context);
174
const telemetryBuilder = new NextEditProviderTelemetryBuilder(gitExtensionService, notebookService, workspaceService, nextEditProvider.ID, workspace.getDocument(activeDocument.docId)!);
175
176
let nextEditResult: NextEditResult;
177
try {
178
nextEditResult = await nextEditProvider.getNextEdit(activeDocument.docId, context, logContext, CancellationToken.None, telemetryBuilder.nesBuilder);
179
} finally {
180
nextEditProvider.dispose();
181
telemetryBuilder.dispose();
182
}
183
184
const logDocument = logContext.toLogDocument();
185
186
VisualizationTestRun.instance?.addData('prompt', () => logContext.prompt);
187
VisualizationTestRun.instance?.addData('log', () => logDocument, 'log_copilotmd');
188
VisualizationTestRun.instance?.reload();
189
190
if (stestRuntime) {
191
stestRuntime.writeFile('nesLogContext.json', JSON.stringify(logDocument, null, 2), NES_LOG_CONTEXT_TAG);
192
}
193
194
const targetDocId = nextEditResult.result?.targetDocumentId;
195
const targetDocument = targetDocId !== undefined ? assertReturnsDefined(historyContext.getDocument(targetDocId)) : activeDocument;
196
197
const aiRootedEdit = new RootedEdit(targetDocument.lastEdit.getEditedState(), nextEditResult.result?.edit?.toEdit() ?? StringEdit.empty);
198
199
if (!nextEditResult.result || !nextEditResult.result.edit) {
200
return {
201
aiEditDocumentUri: targetDocument.docId,
202
aiEditDocumentValue: aiRootedEdit.base
203
};
204
}
205
206
if (stestRuntime) {
207
const serializedNextEdit: ISerializedFileEdit = {
208
id: getUserFriendlyFilePath(targetDocument.docId),
209
languageId: getLanguageIdFromDocumentId(targetDocument.docId),
210
original: aiRootedEdit.base.value,
211
modified: aiRootedEdit.getEditedState().value,
212
};
213
stestRuntime.writeFile('proposedNextEdit.json', JSON.stringify(serializedNextEdit, null, 2), NEXT_EDIT_SUGGESTION_TAG);
214
}
215
216
VisualizationTestRun.instance?.addData('proposedNextEdit', () => ({
217
...{ $fileExtension: 'diff.w' },
218
original: aiRootedEdit.base.value,
219
modified: aiRootedEdit.getEditedState().value,
220
}));
221
222
return {
223
aiRootedEdit,
224
aiEdit: aiRootedEdit.edit,
225
aiEditDocumentUri: targetDocument.docId,
226
nextEdit: nextEditResult.result.edit,
227
textAfterAiEdit: aiRootedEdit.getEditedState(),
228
aiEditDocumentValue: aiRootedEdit.base,
229
};
230
}
231
}
232
233
function getLanguageIdFromDocumentId(docId: DocumentId): string {
234
return getLanguageForResource(URI.file(`/path/file.${docId.extension}`)).languageId;
235
}
236
237
function getUserFriendlyFilePath(docId: DocumentId): string {
238
return basename(docId.path);
239
}
240
241
async function getScoredEditsFilePath(test: ITestInformation, recordingFilePath: string | undefined): Promise<string> {
242
const paths: string[] = [];
243
244
if (test.testFileName !== undefined) {
245
const testDirName = dirname(test.testFileName);
246
const filePath = join(testDirName, 'scores', sanitizeFileName(stripTestFlavor(test.fullTestName)) + '.scoredEdits.w.json');
247
paths.push(filePath);
248
}
249
250
if (recordingFilePath !== undefined) {
251
const path = recordingFilePath.replace('recording.w.json', 'scoredEdits.w.json');
252
if (path === recordingFilePath) {
253
throw new BugIndicatingError();
254
}
255
paths.push(path);
256
}
257
258
for (let i = 0; i < paths.length; i++) {
259
if (i === paths.length - 1) {
260
return paths[i];
261
} else {
262
if (await fileExists(paths[i]) && !await fileExists(paths[i + 1])) {
263
await rename(paths[i], paths[i + 1]);
264
}
265
}
266
}
267
268
throw new BugIndicatingError();
269
}
270
271
function sanitizeFileName(name: string) {
272
return name.replace(/[^a-z0-9 \[\]-]/gi, '_');
273
}
274
275
/** This's used to make sure different flavors of a single test reuse the same scoring file. */
276
function stripTestFlavor(name: string) {
277
return name.replace(/ \(\[([a-zA-Z0-9\-])+\]\)/, '');
278
}
279
280