Path: blob/main/extensions/copilot/test/simulation/externalScenarios.ts
13388 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*--------------------------------------------------------------------------------------------*/4import assert from 'assert';5import * as fs from 'fs';6import * as path from 'path';7import { Intent } from '../../src/extension/common/constants';8import { ILanguageFeaturesService } from '../../src/platform/languages/common/languageFeaturesService';9import { IDeserializedWorkspaceState } from '../../src/platform/test/node/promptContextModel';10import { TestingServiceCollection } from '../../src/platform/test/node/services';11import { SimulationWorkspace } from '../../src/platform/test/node/simulationWorkspace';12import { ITestProvider } from '../../src/platform/testing/common/testProvider';13import { assertType } from '../../src/util/vs/base/common/types';14import { URI } from '../../src/util/vs/base/common/uri';15import { SyncDescriptor } from '../../src/util/vs/platform/instantiation/common/descriptors';16import { Range } from '../../src/vscodeTypes';17import { ISimulationTestRuntime, SimulationSuite, SimulationTest, SimulationTestFunction, SimulationTestRuntime } from '../base/stest';18import { fetchConversationScenarios, IConversationTestCase, Scenario } from '../e2e/scenarioLoader';19import { generateScenarioTestRunner } from '../e2e/scenarioTest';20import { simulateInlineChat } from './inlineChatSimulator';21import { LSIFLanguageFeaturesService } from './language/lsifLanguageFeatureService';22import { simulatePanelCodeMapper } from './panelCodeMapperSimulator';23import { INLINE_CHANGED_DOC_TAG, SIDEBAR_RAW_RESPONSE_TAG } from './shared/sharedTypes';24import { SimulationTestProvider } from './simulationTestProvider';25import { EditTestStrategy, IDeserializedWorkspaceStateBasedScenario } from './types';2627export interface ITestDiscoveryOptions {28chatKind: 'inline' | 'panel';29applyChatCodeBlocks?: boolean;30}3132/**33* Discovers test scenarios in a given root folder.34*35* This function recursively searches through the root folder and its subfolders to find any '.conversation.json' files.36* If a '.conversation.json' file is found in a folder, a simulation suite is created for that folder.37* If no '.conversation.json' file is found, the function will recursively search through the subfolders.38*39* @param rootFolder - The root folder to start the search from.40* @param chatKind - The type of chat to be simulated, either 'inline' or 'panel'.41*42* @returns A promise that resolves to an array of SimulationSuite objects, each representing a test scenario.43*/44export async function discoverTests(rootFolder: string, options: ITestDiscoveryOptions): Promise<SimulationSuite[]> {4546const rootFolderContents = await fs.promises.readdir(rootFolder, { withFileTypes: true });4748const containsConversationFile = rootFolderContents.some(f => f.isFile() && f.name.endsWith('.conversation.json'));4950if (containsConversationFile) {51return [createSimulationSuite(rootFolder, options)];52} else {53const foldersWithScenarios = rootFolderContents.filter(f => f.isDirectory()).map(f => path.join(rootFolder, f.name));54if (foldersWithScenarios.length === 0) {55return [];56}57const scenarios = await Promise.all(foldersWithScenarios.map(f => discoverTests(f, options)));58return scenarios.flat();59}60}6162function createSimulationSuite(folderWithScenarios: string, options: ITestDiscoveryOptions): SimulationSuite {63const suiteName = path.basename(folderWithScenarios);64const chatKind = options.chatKind ?? 'panel';65const suite = new SimulationSuite({ title: suiteName, location: chatKind });66const scenarios = fetchConversationScenarios(folderWithScenarios);6768for (const scenario of scenarios) {69if (chatKind === 'inline') {70for (const conversation of scenario) {71const runner = generateInlineScenarioTestRunner(conversation);72const testName = conversation.name.replace(/.conversation\.json$/, '');73const conversationPath = path.join(conversation.scenarioFolderPath, conversation.name);74suite.tests.push(new SimulationTest({ description: testName }, { conversationPath, scenarioFolderPath: conversation.scenarioFolderPath, stateFile: conversation.stateFile }, suite, runner));75}76} else {77const isSlashEdit = scenario[0].question.startsWith(`/${Intent.Edit}`) || scenario[0].question.startsWith(`/${Intent.Agent}`);78const testName = scenario[0].name.replace(/.conversation\.json$/, '');79const conversationPath = path.join(scenario[0].scenarioFolderPath, scenario[0].name);80let runner: SimulationTestFunction;8182for (const conversation of scenario) {83if (options.applyChatCodeBlocks) {84conversation.applyChatCodeBlocks = true;85}86}8788if (isSlashEdit) {89// /edit in the sidebar needs more special handling90runner = generateSlashEditScenarioTestRunner(scenario);91} else {92runner = generateScenarioTestRunner(scenario, async (accessor, question, userVisibleAnswer, rawResponse) => {93accessor.get(ISimulationTestRuntime).writeFile(`${testName}.md`, rawResponse, SIDEBAR_RAW_RESPONSE_TAG);94return { success: true };95});96}9798suite.tests.push(new SimulationTest({ description: testName }, { conversationPath, scenarioFolderPath: scenario[0].scenarioFolderPath, stateFile: scenario[0].stateFile, }, suite, runner));99}100}101102return suite;103}104105function generateSlashEditScenarioTestRunner(scenario: Scenario): SimulationTestFunction {106return async (testingServiceCollection) => {107108assert(scenario.length > 0, 'Expected at least 1 conversation in the scenario');109assertType(scenario[0].getState !== undefined, 'Expected state to be defined in the first conversation test case');110for (let i = 1; i < scenario.length; i++) {111assertType(scenario[i].getState === undefined, 'Expected state to be undefined in subsequent conversations');112}113114const state = scenario[0].getState();115116const scenario2: IDeserializedWorkspaceStateBasedScenario = {117workspaceState: state,118scenarioFolderPath: scenario[0].scenarioFolderPath,119queries: scenario.map((conversation, index) => {120return {121query: conversation.question,122expectedIntent: undefined,123validate: async (outcome, workspace, accessor) => assert.ok(true),124};125}),126extraWorkspaceSetup: (workspace) => extraWorkspaceSetup(testingServiceCollection, state, workspace),127};128129await simulatePanelCodeMapper(testingServiceCollection, scenario2, EditTestStrategy.Edits);130};131}132133function generateInlineScenarioTestRunner(conversation: IConversationTestCase): (testingServiceCollection: TestingServiceCollection) => Promise<void> {134return async (testingServiceCollection) => {135136assertType(conversation.getState !== undefined, 'Expected state to be defined in conversation test case');137138const state = conversation.getState();139140const scenario: IDeserializedWorkspaceStateBasedScenario = {141workspaceState: state,142scenarioFolderPath: conversation.scenarioFolderPath,143queries: [{144query: conversation.question,145expectedIntent: undefined,146validate: async (outcome, workspace, accessor) => assert.ok(true),147}],148extraWorkspaceSetup: (workspace) => extraWorkspaceSetup(testingServiceCollection, state, workspace),149onBeforeStart: async (accessor) => {150const testContext = accessor.get(ISimulationTestRuntime);151const dataToLog = [152`The conversation input contained the following data.`,153`Name: ${conversation.name}`,154`Query: ${conversation.question}`,155`State: \n${JSON.stringify(state)}`156].join('\n');157testContext.log(dataToLog);158}159};160161await simulateInlineChat(testingServiceCollection, scenario);162};163}164165function extraWorkspaceSetup(testingServiceCollection: TestingServiceCollection, state: IDeserializedWorkspaceState, workspace: SimulationWorkspace): void {166if (state.lsifIndex) {167testingServiceCollection.define(ILanguageFeaturesService, new SyncDescriptor(168LSIFLanguageFeaturesService,169[170workspace,171path.join(state.workspaceFolders![0].fsPath, state.lsifIndex),172]173));174}175if (state.testFailures && state.workspaceFolders) {176testingServiceCollection.define(ITestProvider, new SimulationTestProvider(state.testFailures.map(f => ({177message: f.message,178testRange: new Range(f.line, f.column, f.line, f.column),179uri: URI.file(path.join(state.workspaceFolders![0].fsPath, f.file_path))180}))));181}182}183184export class ExternalSimulationTestRuntime extends SimulationTestRuntime {185186constructor(187baseDir: string,188testOutcomeDir: string,189runNumber: number190) {191super(baseDir, testOutcomeDir, runNumber);192}193194override async writeFile(filename: string, contents: Uint8Array | string, tag: string): Promise<string> {195if (tag === INLINE_CHANGED_DOC_TAG) {196// This is a write file for a workspace file, we'll rename it to <basename>.post.<ext>197const ext = path.extname(filename);198const basename = path.basename(filename, ext);199filename = `${basename}.post${ext}`;200}201return super.writeFile(filename, contents, tag);202}203204protected override massageFilename(filename: string): string {205const ext = path.extname(filename);206const basename = path.basename(filename, ext);207return `${basename}-${this.runNumber}${ext}`;208}209}210211212