Path: blob/main/extensions/copilot/test/simulation/inlineEdit/inlineEditTester.ts
13394 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 { rename } from 'fs/promises';6import { basename, dirname, join } from 'path';7import { VisualizationTestRun } from '../../../src/extension/inlineChat/node/rendererVisualization';8import { IRecordingInformation, ObservableWorkspaceRecordingReplayer } from '../../../src/extension/inlineEdits/common/observableWorkspaceRecordingReplayer';9import { createNextEditProvider } from '../../../src/extension/inlineEdits/node/createNextEditProvider';10import { DebugRecorder } from '../../../src/extension/inlineEdits/node/debugRecorder';11import { NESInlineCompletionContext, NextEditProvider } from '../../../src/extension/inlineEdits/node/nextEditProvider';12import { NextEditProviderTelemetryBuilder } from '../../../src/extension/inlineEdits/node/nextEditProviderTelemetry';13import { NextEditResult } from '../../../src/extension/inlineEdits/node/nextEditResult';14import { ConfigKey, IConfigurationService } from '../../../src/platform/configuration/common/configurationService';15import { IGitExtensionService } from '../../../src/platform/git/common/gitExtensionService';16import { DocumentId } from '../../../src/platform/inlineEdits/common/dataTypes/documentId';17import { RootedEdit } from '../../../src/platform/inlineEdits/common/dataTypes/edit';18import { deserializeStringEdit, SerializedEdit, serializeStringEdit } from '../../../src/platform/inlineEdits/common/dataTypes/editUtils';19import { InlineEditRequestLogContext } from '../../../src/platform/inlineEdits/common/inlineEditLogContext';20import { ObservableGit } from '../../../src/platform/inlineEdits/common/observableGit';21import { ObservableWorkspace } from '../../../src/platform/inlineEdits/common/observableWorkspace';22import { IHistoryContextProvider } from '../../../src/platform/inlineEdits/common/workspaceEditTracker/historyContextProvider';23import { NesHistoryContextProvider } from '../../../src/platform/inlineEdits/common/workspaceEditTracker/nesHistoryContextProvider';24import { NesXtabHistoryTracker } from '../../../src/platform/inlineEdits/common/workspaceEditTracker/nesXtabHistoryTracker';25import { INotebookService } from '../../../src/platform/notebook/common/notebookService';26import { IExperimentationService } from '../../../src/platform/telemetry/common/nullExperimentationService';27import { TestingServiceCollection } from '../../../src/platform/test/node/services';28import { IWorkspaceService } from '../../../src/platform/workspace/common/workspaceService';29import { TaskQueue } from '../../../src/util/common/async';30import { getLanguageForResource } from '../../../src/util/common/languages';31import { CachedFunction } from '../../../src/util/vs/base/common/cache';32import { CancellationToken } from '../../../src/util/vs/base/common/cancellation';33import { BugIndicatingError } from '../../../src/util/vs/base/common/errors';34import { assertReturnsDefined } from '../../../src/util/vs/base/common/types';35import { URI } from '../../../src/util/vs/base/common/uri';36import { generateUuid } from '../../../src/util/vs/base/common/uuid';37import { StringEdit, StringReplacement } from '../../../src/util/vs/editor/common/core/edits/stringEdit';38import { StringText } from '../../../src/util/vs/editor/common/core/text/abstractText';39import { IInstantiationService, ServicesAccessor } from '../../../src/util/vs/platform/instantiation/common/instantiation';40import { ISimulationTestRuntime } from '../../base/stest';41import { CriticalError } from '../../testExecutor';42import { fileExists } from '../../util';43import { ISerializedFileEdit, ISerializedNesUserEditsHistory, NES_LOG_CONTEXT_TAG, NES_USER_EDITS_HISTORY_TAG, NEXT_EDIT_SUGGESTION_TAG } from '../shared/sharedTypes';44import { ITestInformation } from '../testInformation';45import { IInlineEditBaseFile, ILoadedFile } from './fileLoading';46import { inlineEditScoringService } from './inlineEditScoringService';4748export interface IInlineEditTest {49recentEdit: IInlineEditTestDocument | IInlineEditTestDocument[];50}5152export interface IInlineEditTestDocument {53base: IInlineEditBaseFile;54edit: SerializedEdit;55}5657export interface IInlineEditTestRunResult {58aiEditDocumentUri: DocumentId;59aiEditDocumentValue: StringText;60nextUserEdit: StringEdit;61recordingData: IRecordingInformation;62aiRootedEdit?: RootedEdit;63aiEdit?: StringEdit;64nextEdit?: StringReplacement;65textAfterAiEdit?: StringText;66}6768export class EditNotScoredError extends CriticalError {69constructor(scoredEditsFilePath: string) {70super(`Edit is not scored yet in \n${scoredEditsFilePath}\n! Please manually score it and run the test again.`);71}72}7374export type NextCursorPosition = /* lineNumber (1-based) */ number | { cursorOffset: number };7576export class InlineEditTester {77constructor(78private readonly _includeNextEditSelection: boolean = false,79) { }8081private readonly _renameQueues = new CachedFunction({ getCacheKey: JSON.stringify }, (arg: unknown) => new TaskQueue());8283async runAndScoreTestFromRecording(collection: TestingServiceCollection, recording: ILoadedFile): Promise<void> {84const { isScored, scoredEditsFilePath } = await this.runAndScoreFromRecording(collection, recording);85if (!isScored) {86throw new EditNotScoredError(scoredEditsFilePath);87}88}8990async runAndScoreFromRecording(collectionOrAccessor: TestingServiceCollection | ServicesAccessor, recording: ILoadedFile): Promise<{ result: IInlineEditTestRunResult; scoredEditsFilePath: string; isScored: boolean }> {91const accessor = collectionOrAccessor instanceof TestingServiceCollection ? collectionOrAccessor.createTestingAccessor() : collectionOrAccessor;92const recordingData = JSON.parse(recording.fileContents) as IRecordingInformation;93const result = await this.runTestFromRecording(accessor, recordingData);94const testInfo = accessor.get(ITestInformation);9596const scoredEditsFilePath = await this._renameQueues.get([testInfo.testFileName, recording.filePath]).schedule(() => getScoredEditsFilePath(testInfo, recording.filePath));9798const score = await inlineEditScoringService.scoreEdit(scoredEditsFilePath, { kind: 'recording', recording: result.recordingData }, result.aiEditDocumentUri, result.aiEditDocumentValue, result.aiRootedEdit);99100if (!score) {101return { result, scoredEditsFilePath, isScored: false };102} else {103const runtime = accessor.get(ISimulationTestRuntime);104runtime.setExplicitScore(score.getScoreValue());105return { result, scoredEditsFilePath, isScored: true };106}107}108109async runTestFromRecording(accessor: ServicesAccessor, recordingData: IRecordingInformation): Promise<IInlineEditTestRunResult> {110const replayer = new ObservableWorkspaceRecordingReplayer(recordingData, this._includeNextEditSelection);111const obsGit = accessor.get(IInstantiationService).createInstance(ObservableGit);112const historyContextProvider = new NesHistoryContextProvider(replayer.workspace, obsGit);113const nesXtabHistoryTracker = new NesXtabHistoryTracker(replayer.workspace, undefined, accessor.get(IConfigurationService), accessor.get(IExperimentationService));114const debugRecorder = new DebugRecorder(replayer.workspace);115116const { lastDocId } = replayer.replay();117118const expectedEdit = deserializeStringEdit(recordingData.nextUserEdit?.edit ?? serializeStringEdit(StringEdit.empty));119const result = await this._runTest(accessor, lastDocId, replayer.workspace, historyContextProvider, nesXtabHistoryTracker, debugRecorder);120121const r = { ...result, nextUserEdit: expectedEdit, recordingData };122return r;123}124125private async _runTest(accessor: ServicesAccessor, docId: DocumentId, workspace: ObservableWorkspace, historyContextProvider: IHistoryContextProvider, nesXtabHistoryTracker: NesXtabHistoryTracker, debugRecorder: DebugRecorder | undefined) {126const instaService = accessor.get(IInstantiationService);127const configService = accessor.get(IConfigurationService);128const expService = accessor.get(IExperimentationService);129const gitExtensionService = accessor.get(IGitExtensionService);130const notebookService = accessor.get(INotebookService);131const workspaceService = accessor.get(IWorkspaceService);132133const history = historyContextProvider.getHistoryContext(docId)!;134let i = 0;135for (const e of history.documents) {136i++;137VisualizationTestRun.instance?.addData('recentEdit_' + i, () => ({138...{ $fileExtension: 'diff.w' },139original: e.lastEdit.base.value,140modified: e.lastEdit.getEditedState().value,141}));142}143144const stestRuntime = (() => {145try {146return accessor.get(ISimulationTestRuntime);147} catch {148return undefined;149}150})();151152if (stestRuntime) {153const nesUserEditHistory: ISerializedNesUserEditsHistory = {154edits: history.documents.map((doc): ISerializedFileEdit => ({155id: getUserFriendlyFilePath(doc.docId),156languageId: getLanguageIdFromDocumentId(doc.docId),157original: doc.lastEdit.base.value,158modified: doc.lastEdit.getEditedState().value,159})),160currentDocumentIndex: history.documents.length - 1,161};162stestRuntime.writeFile('nesUserEditHistory.json', JSON.stringify(nesUserEditHistory, null, 2), NES_USER_EDITS_HISTORY_TAG);163}164165const nextEditProviderId = configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsProviderId, expService);166const statelessNextEditProvider = createNextEditProvider(nextEditProviderId, instaService);167const nextEditProvider = instaService.createInstance(NextEditProvider, workspace, statelessNextEditProvider, historyContextProvider, nesXtabHistoryTracker, debugRecorder);168169const historyContext = historyContextProvider.getHistoryContext(docId)!;170const activeDocument = historyContext.getMostRecentDocument(); // TODO171const context: NESInlineCompletionContext = { triggerKind: 1, selectedCompletionInfo: undefined, requestUuid: generateUuid(), requestIssuedDateTime: Date.now(), earliestShownDateTime: Date.now() + 200, enforceCacheDelay: false };172const logContext = new InlineEditRequestLogContext(activeDocument.docId.toString(), 1, context);173const telemetryBuilder = new NextEditProviderTelemetryBuilder(gitExtensionService, notebookService, workspaceService, nextEditProvider.ID, workspace.getDocument(activeDocument.docId)!);174175let nextEditResult: NextEditResult;176try {177nextEditResult = await nextEditProvider.getNextEdit(activeDocument.docId, context, logContext, CancellationToken.None, telemetryBuilder.nesBuilder);178} finally {179nextEditProvider.dispose();180telemetryBuilder.dispose();181}182183const logDocument = logContext.toLogDocument();184185VisualizationTestRun.instance?.addData('prompt', () => logContext.prompt);186VisualizationTestRun.instance?.addData('log', () => logDocument, 'log_copilotmd');187VisualizationTestRun.instance?.reload();188189if (stestRuntime) {190stestRuntime.writeFile('nesLogContext.json', JSON.stringify(logDocument, null, 2), NES_LOG_CONTEXT_TAG);191}192193const targetDocId = nextEditResult.result?.targetDocumentId;194const targetDocument = targetDocId !== undefined ? assertReturnsDefined(historyContext.getDocument(targetDocId)) : activeDocument;195196const aiRootedEdit = new RootedEdit(targetDocument.lastEdit.getEditedState(), nextEditResult.result?.edit?.toEdit() ?? StringEdit.empty);197198if (!nextEditResult.result || !nextEditResult.result.edit) {199return {200aiEditDocumentUri: targetDocument.docId,201aiEditDocumentValue: aiRootedEdit.base202};203}204205if (stestRuntime) {206const serializedNextEdit: ISerializedFileEdit = {207id: getUserFriendlyFilePath(targetDocument.docId),208languageId: getLanguageIdFromDocumentId(targetDocument.docId),209original: aiRootedEdit.base.value,210modified: aiRootedEdit.getEditedState().value,211};212stestRuntime.writeFile('proposedNextEdit.json', JSON.stringify(serializedNextEdit, null, 2), NEXT_EDIT_SUGGESTION_TAG);213}214215VisualizationTestRun.instance?.addData('proposedNextEdit', () => ({216...{ $fileExtension: 'diff.w' },217original: aiRootedEdit.base.value,218modified: aiRootedEdit.getEditedState().value,219}));220221return {222aiRootedEdit,223aiEdit: aiRootedEdit.edit,224aiEditDocumentUri: targetDocument.docId,225nextEdit: nextEditResult.result.edit,226textAfterAiEdit: aiRootedEdit.getEditedState(),227aiEditDocumentValue: aiRootedEdit.base,228};229}230}231232function getLanguageIdFromDocumentId(docId: DocumentId): string {233return getLanguageForResource(URI.file(`/path/file.${docId.extension}`)).languageId;234}235236function getUserFriendlyFilePath(docId: DocumentId): string {237return basename(docId.path);238}239240async function getScoredEditsFilePath(test: ITestInformation, recordingFilePath: string | undefined): Promise<string> {241const paths: string[] = [];242243if (test.testFileName !== undefined) {244const testDirName = dirname(test.testFileName);245const filePath = join(testDirName, 'scores', sanitizeFileName(stripTestFlavor(test.fullTestName)) + '.scoredEdits.w.json');246paths.push(filePath);247}248249if (recordingFilePath !== undefined) {250const path = recordingFilePath.replace('recording.w.json', 'scoredEdits.w.json');251if (path === recordingFilePath) {252throw new BugIndicatingError();253}254paths.push(path);255}256257for (let i = 0; i < paths.length; i++) {258if (i === paths.length - 1) {259return paths[i];260} else {261if (await fileExists(paths[i]) && !await fileExists(paths[i + 1])) {262await rename(paths[i], paths[i + 1]);263}264}265}266267throw new BugIndicatingError();268}269270function sanitizeFileName(name: string) {271return name.replace(/[^a-z0-9 \[\]-]/gi, '_');272}273274/** This's used to make sure different flavors of a single test reuse the same scoring file. */275function stripTestFlavor(name: string) {276return name.replace(/ \(\[([a-zA-Z0-9\-])+\]\)/, '');277}278279280